Compare commits
17
Commits
706d247b58
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39f82ade31 | ||
|
|
a65f953f17 | ||
|
|
3dad65c487 | ||
|
|
f27bb17669 | ||
|
|
7b8c932ab7 | ||
|
|
21b1945769 | ||
|
|
d47704c6ec | ||
|
|
91ca29d181 | ||
|
|
a29f38eaa9 | ||
|
|
0c9333858c | ||
|
|
ecdbf4f0e1 | ||
|
|
2279e2140a | ||
|
|
fa2d0f12fc | ||
|
|
2924f7627d | ||
|
|
b90a99ec39 | ||
|
|
5ed75eb470 | ||
|
|
51b4fad386 |
No files matched your search
@@ -241,6 +241,7 @@ add_executable(udpscope
|
||||
src/source/registry.cpp
|
||||
src/source/udps_source.cpp
|
||||
src/source/udps_kind.cpp
|
||||
src/source/tcps_kind.cpp
|
||||
src/source/probe_source.cpp
|
||||
src/source/e1608/e1608_link.cpp
|
||||
src/source/e1608/e1608_device.cpp
|
||||
|
||||
@@ -129,7 +129,11 @@ rect plus a `kMaximizeRevealPx` of slack, per row) and is invisible otherwise, s
|
||||
decorated by controls nobody is reaching for; one click makes that row fill
|
||||
the plot area and the same button restores the whole arrangement; the plot's right-click menu
|
||||
offers the same as **Full screen this figure** / **Restore the layout**, and a layout of a single
|
||||
figure carries no button (there is nothing to enlarge *to*). It is a *view* — the row set
|
||||
figure carries no button (there is nothing to enlarge *to*). Beside the toggle, and with the
|
||||
same show-on-approach, a **detach** button moves the figure into a window of its own (removed
|
||||
from the main window's rows, which save the change); the detached window carries an **attach**
|
||||
button in the same corner — its figure returns to the main window's rows and the window closes.
|
||||
It is a *view* — the row set
|
||||
saves every row's state on the way in and re-applies it on the way out (`RowState` in
|
||||
`plot_host.*`), so nothing plotted moves and nothing is saved. A plot's right-click menu has
|
||||
**Open this plot in a new window**, which copies that figure — its cells, their width shares and
|
||||
@@ -138,6 +142,32 @@ and `PlotArea`; the overflow menu's **New scope window** opens one with an empty
|
||||
window is another *view* of the same acquisition: the main window's tick binds and acquires its
|
||||
rowset under the same model mutex, mirrors the main window's time window (or its pin) onto it
|
||||
every frame, and pumps its render — so the two windows never disagree about the data, the display
|
||||
mode or the span. **A signal badge dragged onto a scope window's plot lands there** — the same
|
||||
drop the main plot takes, into that window's own row set, on the cell the pointer is over — and
|
||||
the signal page's *Plot on* buttons list **every window**: one group per window (named, `In
|
||||
"plot 2"`), one button per plot of it. **The session remembers every extra window**, but a launch never opens one: a window is a view
|
||||
you closed on purpose, and a start that tiled them onto the desktop would take that decision for
|
||||
you. The overflow menu's **Reopen closed windows (N)** brings them back, arrangement and all; a
|
||||
window that was *attached* is not remembered (its figure is on the main window again), and a
|
||||
detached figure is preserved by its window's entry, so quitting with one open no longer loses it.
|
||||
|
||||
A scope window draws the **same instruments as the main window** — the two cursors (draggable
|
||||
there, and shared: moving one in any window moves it everywhere), the gate band, the trigger
|
||||
marker and the measurements' marks on the traces each window actually plots. They have one owner
|
||||
(the main window's plot set) and are written into every window each frame, so no window can drift.
|
||||
|
||||
A scope window also **carries its own layout editor**: `L` (or the grid button in its corner,
|
||||
revealed like the others) shows the same `+`/`×`/Done controls the main window's editor has, and
|
||||
they change *that* window's rows — so a second window can hold several figures as easily as the
|
||||
main one. A right-click on a scope window's plot opens **its own menu** — y axis (auto or a fixed
|
||||
range), *Fit X axis*, *Fit all*, remove a trace — acting on that window's rows (the x fit resets the **shared** time span, so every view follows); a detached
|
||||
window's menu carries **Attach back to the main window** beside them. A window that cannot paint
|
||||
says so on stderr and in the status bar after two seconds, naming its **GSK renderer**: a blank
|
||||
plot is indistinguishable from a context that failed, and the GTK **Vulkan** renderer is known to
|
||||
interact badly with an embedded `GtkGLArea` in a second toplevel — the sentence then says to
|
||||
restart with `GSK_RENDERER=gl`. Each scope window also carries a **tick callback of its own**,
|
||||
which keeps its toplevel's frame clock advancing: the main window's tick binds and queues the
|
||||
render, but a second window whose clock never ticks presents nothing.
|
||||
mode or the span.
|
||||
|
||||
**The window.** The plot is the window. Under it sits a fixed-height **badge bar** (36 px). Its left
|
||||
@@ -297,6 +327,27 @@ delivers one frame per datagram. The source is connected on the first valid data
|
||||
was verified on a loopback group (`239.255.42.99` joined on `127.0.0.1`, although this machine's `lo` does not
|
||||
carry the `MULTICAST` flag); a real network, its switches and IGMP were not exercised.
|
||||
|
||||
The fourth built-in kind is **TCPStreamer** (`--source tcps`), a receiver for the MARTe2 `TCPStreamer`
|
||||
datasource, standalone or through a shared `TCPStreamService`. Both speak the UDPStreamer framing over a TCP
|
||||
connection, so it is the UDPS client in a TCP mode: the same CONFIG and DATA, the same channels, calibration and
|
||||
timing. The form takes the server (default 127.0.0.1), the port (44500) and an optional **stream name**: leave
|
||||
it empty for a standalone `TCPStreamer`, which starts sending on connect; name the stream for a
|
||||
`TCPStreamService`, which wants a `SUBSCRIBE` first (the client sends one only when a name is set). On the
|
||||
command line these are `--source tcps --source-opt server=... --source-opt port=... --source-opt stream=...`.
|
||||
The source is disconnected after 2 s of silence or a closed connection and reconnects every 0.5 s; a stream
|
||||
whose frames are more than 2 s apart therefore reconnects continually, as UDPS does. It is verified against
|
||||
`tools/mock_tcpstreamer.py`, not against a MARTe2 application. Its limits:
|
||||
|
||||
- **One stream per connection.** A multiplexed `SUBSCRIBE` list (`a,b,c`) is refused by the form, because a
|
||||
source is single-timebase; merge streams on the server with `TCPStreamJoin` and name the result.
|
||||
- **No packet capture.** The capture is of the UDP socket; the button stays disabled.
|
||||
- **A gap is reported at once.** TCP is ordered, so there is no reorder window: a counter gap is a server-side
|
||||
drop and is counted as lost immediately.
|
||||
- **A frame is at most `UDPS_MAX_DATAGRAM_BYTES` (about 65 KB)**, which the server's `MaxPayloadSize` default of
|
||||
60000 satisfies.
|
||||
- **After a reconnect to a server whose time base restarted** the app prints `the clock stalled at ...`
|
||||
warnings, exactly as for a restarted UDPS stream.
|
||||
|
||||
A session file written before kinds existed has no `source_kind`, and is read as UDPS; a UDPS file is
|
||||
written exactly as before. A kind's capability panels (for instance a source's own controls) appear as
|
||||
pages of the side stack, and are insensitive rather than hidden while no source is connected.
|
||||
@@ -3269,6 +3320,10 @@ silence timeout and then resumes, which is the EMI case end to end.
|
||||
exactly faithful in `strict` mode, and the only reading of the wire that does
|
||||
not invent samples in `accumulate` mode. The info popup and the CONFIG dump on
|
||||
stderr report the element counts.
|
||||
- **The TCPStreamer source takes one stream per connection and has no packet capture.** A multiplexed
|
||||
`SUBSCRIBE` list is not supported (merge on the server with `TCPStreamJoin`), the capture button stays
|
||||
disabled, a gap is reported at once (no reorder window) and a frame is limited to about 65 KB; see "Data
|
||||
sources".
|
||||
- **The event window can be shorter than the buffer.** The trigger latches a window
|
||||
of the event-span selection; the ring holds far more, so with trigger mode off the
|
||||
rest is scrollable history.
|
||||
|
||||
Vendored
+66
-3
@@ -174,6 +174,7 @@ struct udps_client {
|
||||
char server_addr[128];
|
||||
char mcast_group[128];
|
||||
char iface_addr[128];
|
||||
char stream_name[256];
|
||||
|
||||
struct sockaddr_in server_sa;
|
||||
int udp_fd; /**< Unicast receive socket, or joined multicast socket. */
|
||||
@@ -197,6 +198,7 @@ struct udps_client {
|
||||
size_t vals_cap;
|
||||
|
||||
uint8_t *rxbuf;
|
||||
size_t rxbuf_cap; /**< bytes allocated for rxbuf */
|
||||
|
||||
udps_held_t held[UDPS_REORDER_SLOTS];
|
||||
size_t held_bytes; /**< payload bytes currently held */
|
||||
@@ -1149,8 +1151,49 @@ static int connect_multicast(udps_client_t *c) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** TCP transport: connect, then SUBSCRIBE when a stream name is set. */
|
||||
static int connect_tcp(udps_client_t *c) {
|
||||
struct timeval tv;
|
||||
size_t name_len = strlen(c->stream_name);
|
||||
|
||||
c->tcp_fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (c->tcp_fd < 0) {
|
||||
return fail(c, "TCP socket() failed: %s", strerror(errno));
|
||||
}
|
||||
set_recv_buffer(c->tcp_fd, c->cfg.recv_buffer_bytes);
|
||||
/* Linux bounds a blocking connect() with SO_SNDTIMEO, so an unreachable host
|
||||
* cannot hold the polling thread for minutes. */
|
||||
tv.tv_sec = 3;
|
||||
tv.tv_usec = 0;
|
||||
(void)setsockopt(c->tcp_fd, SOL_SOCKET, SO_SNDTIMEO, &tv, (socklen_t)sizeof tv);
|
||||
if (connect(c->tcp_fd, (struct sockaddr *)&c->server_sa,
|
||||
(socklen_t)sizeof c->server_sa) != 0) {
|
||||
int e = errno;
|
||||
close_sockets(c);
|
||||
return fail(c, "TCP connect to %s:%u failed: %s", c->server_addr,
|
||||
(unsigned)c->cfg.server_port, strerror(e));
|
||||
}
|
||||
/* A half-received frame must not wedge the poll loop (read_exact_tcp blocks). */
|
||||
tv.tv_sec = 1;
|
||||
(void)setsockopt(c->tcp_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, (socklen_t)sizeof tv);
|
||||
|
||||
if (name_len > 0u) {
|
||||
uint8_t pkt[UDPS_HEADER_SIZE + 255u];
|
||||
build_header(pkt, UDPS_PKT_SUBSCRIBE, 0u, 0u, 1u, (uint32_t)name_len);
|
||||
memcpy(pkt + UDPS_HEADER_SIZE, c->stream_name, name_len);
|
||||
if (send(c->tcp_fd, pkt, UDPS_HEADER_SIZE + name_len, MSG_NOSIGNAL) !=
|
||||
(ssize_t)(UDPS_HEADER_SIZE + name_len)) {
|
||||
int e = errno;
|
||||
close_sockets(c);
|
||||
return fail(c, "SUBSCRIBE failed: %s", strerror(e));
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int do_connect(udps_client_t *c) {
|
||||
int rc = (c->mcast_group[0] != '\0') ? connect_multicast(c)
|
||||
int rc = c->cfg.use_tcp ? connect_tcp(c)
|
||||
: (c->mcast_group[0] != '\0') ? connect_multicast(c)
|
||||
: connect_unicast(c);
|
||||
if (rc != 0) {
|
||||
c->disconnect_t = now_mono();
|
||||
@@ -1174,7 +1217,10 @@ static void do_disconnect(udps_client_t *c, const char *why) {
|
||||
if (!c->connected) {
|
||||
return;
|
||||
}
|
||||
if (!c->cfg.use_tcp) {
|
||||
/* TCP: a write to a peer that closed would raise SIGPIPE; the server sees the close itself. */
|
||||
(void)send_control(c, UDPS_PKT_DISCONNECT);
|
||||
}
|
||||
close_sockets(c);
|
||||
c->connected = 0;
|
||||
c->disconnect_t = now_mono();
|
||||
@@ -1211,7 +1257,7 @@ static int read_tcp_frame(udps_client_t *c) {
|
||||
if (udps_parse_header(c->rxbuf, UDPS_HEADER_SIZE, &h) != 0) {
|
||||
return -1; /* desynchronised: the stream cannot be resynced */
|
||||
}
|
||||
if (h.payload_bytes > (uint32_t)(UDPS_RX_BUF_BYTES - UDPS_HEADER_SIZE)) {
|
||||
if (h.payload_bytes > c->rxbuf_cap - UDPS_HEADER_SIZE) {
|
||||
return -1;
|
||||
}
|
||||
if (h.payload_bytes > 0u &&
|
||||
@@ -1267,17 +1313,28 @@ udps_client_t *udps_client_create(const udps_client_config_t *cfg) {
|
||||
if (c == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
if (cfg->use_tcp && cfg->stream_name != NULL && strlen(cfg->stream_name) > 255u) {
|
||||
free(c);
|
||||
return NULL;
|
||||
}
|
||||
c->cfg = *cfg;
|
||||
c->udp_fd = -1;
|
||||
c->tcp_fd = -1;
|
||||
copy_str(c->server_addr, sizeof c->server_addr, cfg->server_addr);
|
||||
copy_str(c->mcast_group, sizeof c->mcast_group, cfg->multicast_group);
|
||||
copy_str(c->iface_addr, sizeof c->iface_addr, cfg->interface_addr);
|
||||
copy_str(c->stream_name, sizeof c->stream_name, cfg->stream_name);
|
||||
/* The config's string pointers must not be used again: they belong to the
|
||||
* caller and the copies above are what the client works from. */
|
||||
c->cfg.server_addr = c->server_addr;
|
||||
c->cfg.multicast_group = (c->mcast_group[0] != '\0') ? c->mcast_group : NULL;
|
||||
c->cfg.interface_addr = (c->iface_addr[0] != '\0') ? c->iface_addr : NULL;
|
||||
c->cfg.stream_name = c->stream_name;
|
||||
if (c->cfg.use_tcp) {
|
||||
c->cfg.multicast_group = NULL;
|
||||
c->mcast_group[0] = '\0';
|
||||
c->cfg.reorder_window_s = 0.0;
|
||||
}
|
||||
|
||||
if (c->cfg.max_packet_bytes == 0u) {
|
||||
c->cfg.max_packet_bytes = 1u * 1024u * 1024u;
|
||||
@@ -1294,7 +1351,13 @@ udps_client_t *udps_client_create(const udps_client_config_t *cfg) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
c->rxbuf = (uint8_t *)malloc(UDPS_RX_BUF_BYTES);
|
||||
/* A UDP datagram is at most UDPS_MAX_DATAGRAM_BYTES; a TCP frame is not fragmented
|
||||
* and may be as large as max_packet_bytes (MARTe's TCPStreamer allows 1 MiB). */
|
||||
c->rxbuf_cap = UDPS_RX_BUF_BYTES;
|
||||
if (c->cfg.use_tcp && (size_t)c->cfg.max_packet_bytes + UDPS_HEADER_SIZE > c->rxbuf_cap) {
|
||||
c->rxbuf_cap = (size_t)c->cfg.max_packet_bytes + UDPS_HEADER_SIZE;
|
||||
}
|
||||
c->rxbuf = (uint8_t *)malloc(c->rxbuf_cap);
|
||||
if (c->rxbuf == NULL) {
|
||||
free(c);
|
||||
return NULL;
|
||||
|
||||
Vendored
+16
-1
@@ -52,7 +52,8 @@ enum {
|
||||
UDPS_PKT_CONFIG = 1, /**< Server -> client: signal metadata. */
|
||||
UDPS_PKT_ACK = 2, /**< Client -> server: keepalive. */
|
||||
UDPS_PKT_CONNECT = 3, /**< Client -> server: open a session. */
|
||||
UDPS_PKT_DISCONNECT = 4 /**< Either direction: close a session. */
|
||||
UDPS_PKT_DISCONNECT = 4, /**< Either direction: close a session. */
|
||||
UDPS_PKT_SUBSCRIBE = 5 /**< Client -> server (TCP stream service): payload = stream name. */
|
||||
};
|
||||
|
||||
/** Sample type codes (udps_signal_t::type_code). */
|
||||
@@ -279,6 +280,20 @@ typedef struct {
|
||||
* the drop-on-late behaviour. Default 0.020.
|
||||
*/
|
||||
double reorder_window_s;
|
||||
/**
|
||||
* Non-zero selects the TCP transport (a MARTe2 TCPStreamer / TCPStreamService):
|
||||
* @c server_port is the TCP port, no CONNECT or DISCONNECT frame is sent, the
|
||||
* server streams CONFIG then DATA on connect, and the reorder window is forced
|
||||
* to 0 (TCP is ordered, so a counter gap is a server-side drop). @c multicast_group,
|
||||
* @c interface_addr and @c data_port are ignored.
|
||||
*/
|
||||
int use_tcp;
|
||||
/**
|
||||
* TCP mode only: when non-empty (1..255 bytes) a SUBSCRIBE frame naming this stream is
|
||||
* sent right after every connect, as a TCPStreamService requires. A plain
|
||||
* TCPStreamer ignores it. NULL or "" sends nothing.
|
||||
*/
|
||||
const char *stream_name;
|
||||
} udps_client_config_t;
|
||||
|
||||
/** Fills @p cfg with the defaults documented in Docs/UDPS-C-Client.md. */
|
||||
|
||||
@@ -32,8 +32,16 @@ What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*.
|
||||
source-neutral, and its pointers are valid **only during `on_data`**: copy what is kept.
|
||||
- The GTK thread reaches a source **only** through `DataSource::post(Command)`; the queue is drained
|
||||
at the top of `poll()`. A panel never calls a source directly.
|
||||
- `UdpsSource` wraps `deps/udpsclient` unchanged; `udps_*` names appear only in
|
||||
- `UdpsSource` wraps `deps/udpsclient` and takes a `Transport` (UDP or TCP); `udps_*` names appear only in
|
||||
`src/source/udps_*.cpp` (`grep -rn "udps_" src` outside those files is a leak of the old coupling).
|
||||
- **The TCPStreamer kind (`tcps`) is `UdpsSource` with `Transport::Tcp`**, not a new `DataSource`: the
|
||||
transport argument sets `use_tcp` / `stream_name` in the client config, defaults the port to 44500 and
|
||||
ignores multicast and interface. `src/source/tcps_kind.cpp` is only the form and the identity (`host:port` or
|
||||
`host:port/stream`). One stream per connection (no multiplexed `SUBSCRIBE` list: a source is
|
||||
single-timebase; merge on the server with `TCPStreamJoin`), and the form refuses a name with a comma.
|
||||
`has_datagrams()` is false, so the capture button stays disabled. `Disconnected` follows the client's 2 s
|
||||
silence timeout or a closed connection, and it reconnects every 0.5 s; a stream whose frames are further
|
||||
apart than 2 s therefore reconnects continually, as UDPS does.
|
||||
- The frame is **single-timebase**. A multi-timebase source (ABScope) will change the interface and
|
||||
the data path together; do not bend one frame to carry two clocks.
|
||||
- A kind's capability panels are pages of the slide-over (`slideover_add_page`), named `source-<id>`. A disconnected source
|
||||
|
||||
@@ -164,6 +164,16 @@ python3 tools/mock_flatudp.py --port 44588 --rate 1000 --signals 'Trigger:uint8,
|
||||
(Quote the `signals` value: zsh globs the `[4]`. The mock takes the same text with or without the trailing `w`;
|
||||
the datagram is the same, `w` only says how the app shows it.)
|
||||
|
||||
`--source tcps` receives a MARTe2 `TCPStreamer` (standalone) or `TCPStreamService` (named stream) over
|
||||
TCP: `server` (default 127.0.0.1), `port` 44500, and `stream` (optional; a `SUBSCRIBE` frame is sent only when it is
|
||||
set). `tools/mock_tcpstreamer.py` serves one client with the `mock_udpstreamer.py` signals (`--port`, `--stream`
|
||||
to require a `SUBSCRIBE`, `--rate`, `--batch`, `--freq`, `--close-after S`, `--drop-every N`).
|
||||
|
||||
```sh
|
||||
python3 tools/mock_tcpstreamer.py --port 44500 --stream fast &
|
||||
./build/udpscope --source tcps --source-opt port=44500 --source-opt stream=fast
|
||||
```
|
||||
|
||||
`tools/mock_udpstreamer.py` has waveform-injection options used by the probes:
|
||||
|
||||
- `--square-harmonics N` shapes `square`: `N` odd harmonics of the ideal step's Fourier
|
||||
@@ -201,7 +211,8 @@ form for a new host. Every probe below passes `--server`, so none of them sees i
|
||||
|
||||
`deps/plotter` is built from source with only the GTK4 adapter enabled
|
||||
(`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a
|
||||
vendored C99 source pair compiled into a static lib. `deps/udpsclient` gained one
|
||||
vendored C99 source pair compiled into a static lib. `deps/udpsclient` gained a TCP
|
||||
transport mode (item 6 below) and one
|
||||
additive entry point for the packet capture — `udps_client_set_datagram_cb()`, firing
|
||||
inside `udps_client_poll()` immediately after the UDP `recv()` returns, before the
|
||||
magic check or reassembly — plus the public `UDPS_MAX_DATAGRAM_BYTES` the capture
|
||||
@@ -227,7 +238,7 @@ handler clears either pointer without knowing which dialog it is. Presenting eit
|
||||
app's plain `GtkApplicationWindow` is supported and warning-free; it was checked.
|
||||
|
||||
`deps/udpsclient` is a vendored C99 source pair and its diff from the upstream source
|
||||
is intentional. Five things were changed:
|
||||
is intentional. Six things were changed:
|
||||
|
||||
1. **A reorder ring of reassembled payloads.** When a packet arrives with a higher counter
|
||||
than expected, the raw datagram is held in a fixed-size ring rather than decoded immediately.
|
||||
@@ -258,6 +269,17 @@ is intentional. Five things were changed:
|
||||
would be the same silent loss by another route. `stats.reorder_overflows` counts the event,
|
||||
so an undersized ring is a reading rather than a mystery; it is surfaced in the exit dump and
|
||||
the `[reseq-probe]` line as `overflows=`.
|
||||
6. **A TCP transport mode.** `udps_client_config_t::use_tcp` / `stream_name` select a TCP
|
||||
connection to a MARTe2 `TCPStreamer` or `TCPStreamService`: `connect_tcp()` (a 3 s connect
|
||||
bound through `SO_SNDTIMEO`, a 1 s receive bound, an optional `SUBSCRIBE` frame,
|
||||
`UDPS_PKT_SUBSCRIBE = 5`, sent only when `stream_name` is set), the existing
|
||||
`read_tcp_frame()` for the frames, the reorder window forced to 0 (TCP is ordered, so a
|
||||
counter gap is a server-side drop and is reported at once), multicast forced off, no
|
||||
DISCONNECT frame on close (a write to a closed peer would raise SIGPIPE), no keepalive ACK
|
||||
and no datagram tap. The receive buffer is `max_packet_bytes + header` (1 MiB by default,
|
||||
matching the server's frame ceiling) in TCP mode, since a TCP frame is never fragmented and
|
||||
a MARTe cycle of large arrays is one frame; it is `UDPS_MAX_DATAGRAM_BYTES` only for UDP. A
|
||||
stream name over 255 bytes is refused at create.
|
||||
|
||||
`deps/plotter` is upstream's own repository and this app's figure support was added
|
||||
there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the
|
||||
|
||||
@@ -357,7 +357,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
(same display mode, same epoch), and its span follows the main window's window or pin every frame. It is
|
||||
therefore a second *view* -- the settle rule is that a scope must never acquire, plan or epoch anything of
|
||||
its own. Closing the main window destroys its scopes first: a leftover toplevel keeps the GApplication's
|
||||
loop alive around a window that is gone (the exit run's hang).
|
||||
loop alive around a window that is gone (the exit run's hang). What a user *arranges* in a scope is the
|
||||
scope's own arrangement, and it takes the same two doors the main window's rows take: a badge **dropped on
|
||||
a scope window's plot** lands in that window's row set on the cell under the pointer (its own drop target,
|
||||
its own `assign_signal_to_scope()`), and the signal page's *Plot on* section carries **one named group per
|
||||
window** -- rebuilt out of `App::scopes` on every fill, and again when a window closes (the destroy handler
|
||||
refreshes the pages before the deferred delete can dangle a button's scope pointer). The change itself is
|
||||
never applied synchronously: a drop arrives from inside the very widget it rearranges, so the rebuild and
|
||||
the area's re-point are the window's next frame in the tick.
|
||||
|
||||
- **A figure row's full-screen is one click from two places, and never from one figure.** The
|
||||
toggle sits on the row's top-right corner, **out of hiding only while the pointer is near that
|
||||
@@ -373,3 +380,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
figure row carries **no** toggle (and no menu entry): there is nothing to enlarge *to*, and a
|
||||
visible no-op is worse than none. The button reappears while a row *is* maximized, because
|
||||
there it is the way back.
|
||||
|
||||
- **A view's arrangement is the view's, editor included.** A scope window holds its own figure
|
||||
rows and edits them with its own `+`/`×`/Done set (`set_scope_layout_editing()`,
|
||||
`rebuild_scope_layout_editor()`, `sync_scope_layout_editor()`): the buttons are tagged with the
|
||||
window and the handlers act on *that* window's row set, so the two editors cannot move each
|
||||
other's figures. `L` acts on the window the key arrived in (a `GtkEventControllerKey` per
|
||||
window; every other accelerator reaches the application through `run_app_action()`), and a
|
||||
pinned layout change is **not** written to the main window's session — the windows themselves
|
||||
are not persisted.
|
||||
@@ -130,3 +130,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
stages the badge texts, which leave with the rest of the over batch over everything. An
|
||||
assertion on badge pixels inside a label's rows is the regression test: with the fill under
|
||||
the text, the label's glyph pixels are still lit inside the badge's rect.
|
||||
|
||||
- **The instruments have one owner: the main window's row set.** The two cursors, the gate band,
|
||||
the trigger marker and the measurement marks are *shared*, not copied per window. The main tick's
|
||||
`sync_scope_instruments()` writes the owner's state into every scope window each frame (the
|
||||
setters early-out, so a steady frame is a handful of comparisons), and a gesture inside a scope
|
||||
window is read back from **that** window's controllers first -- `sync_cursors()`/`sync_gate()`
|
||||
only adopt while their own plot holds the grab, which is exactly "the drag happened here" -- and
|
||||
adopted by the owner before anything is written out. The measurement marks travel by **signal
|
||||
name** (`RowSet::MeasureMark::signal`), so a window finds the row that plots it; a window that
|
||||
does not plot the signal draws none. The search marks are times on the window and are placed on
|
||||
every window by the same per-frame path.
|
||||
@@ -204,3 +204,14 @@ one, which is the only ordering in which a switch cannot lose what it switches f
|
||||
configuration's folder): an upgrade keeps the events it had. The key is a *sanitized* path
|
||||
component, the same rule the configuration files use, so a name typed with a slash cannot
|
||||
leave the directory.
|
||||
|
||||
- **The session remembers extra windows; a launch never opens them.** Each scope or detached
|
||||
window is a `[[window]]` block (`name`, `detached`, and its own `[[window.plot]]` rows -- the
|
||||
same keys the main `[[plot]]` rows carry), written from the open windows' present arrangement
|
||||
plus the ones already closed (`collect_layout()`). Loading puts **all** of them in
|
||||
`App::closed_windows`, never into `app.scopes`: the overflow menu's *Reopen closed windows (N)*
|
||||
is the only path that opens them (`open_scope_window_from_state()`, the one constructor every
|
||||
window uses, then its rows). Closing a window records it (by name, replaced, so close/reopen
|
||||
cycles do not multiply entries); **attaching** records nothing -- its figure is on the main
|
||||
window again -- and a detached figure survives a quit through its window's entry instead of
|
||||
being lost from the main layout.
|
||||
@@ -629,3 +629,11 @@ samples. Three rules keep that honest:
|
||||
its ink is not in the widget's paint tree, so `gtk_snapshot_free_to_node()` on a widget paintable of
|
||||
it returns NULL and the "screenshot" would be an empty file. Drawing it in the store also keeps that
|
||||
class GTK-free, which is what makes the cache testable without a window.
|
||||
|
||||
- **A pin is mirrored as a *range*, never as a pair of numbers that might be spans.** `Range`
|
||||
(`pinned_range()`: `{-pre, +post}`) and the pin's *spans* (`pin_event_window(pre, post)`) are two
|
||||
different things that both look like a pair, and mixing them inverts the window: a second view
|
||||
that handed `pinned_range().min` to `pin_event_window()` pinned to `[+pre, +post]`, and the plot
|
||||
drew nothing — no trace, no axis labels — because the x range was degenerate. Views that mirror a
|
||||
pin use `pin_display_range(x0, x1)`, the range's own form. `[scope-probe] pin_mirror=1` reads the
|
||||
two windows' pins and the scope's x range, and an inverted pin reads `0`.
|
||||
@@ -1422,19 +1422,28 @@ Measured output (viewport pixels; the absolute numbers are the headless window's
|
||||
## `[maximize-probe]`
|
||||
|
||||
`UDPSCOPE_PROBE_MAXIMIZE_MS`. The one-click **full-screen row** toggle, exercised through the
|
||||
`RowSet` call the button's handler makes. **Do not run it with `UDPSCOPE_ENABLE_ALL_MS`**: the
|
||||
enable-all probe's second leg clears the front plot two seconds in, and this probe's `before`
|
||||
read then names an empty row (`row0=`) -- a probe race, not a defect. The full-screen recipe:
|
||||
`RowSet` call the button's handler makes (a probe cannot click an overlay button the frame
|
||||
places), arranged as two single-cell rows with one signal each. The two reads after it are the
|
||||
**corner reveal**: no pointer over the area, then a pointer position in the first row's corner
|
||||
(set directly -- the probe owns the state the motion controller fills). Measured output:
|
||||
|
||||
```
|
||||
[maximize-probe] single_buttons=0 before: rows=2 frows=2 row0=sine row1=square buttons=2; max: rows=1 frows=1 flag=1 row0=sine; back: rows=2 row0=sine row1=square flag=0
|
||||
[maximize-probe] corner: hidden_when_off=1 shown_when_near=1 other_row=0
|
||||
[maximize-probe] single_buttons=0 detach_single=0 before: rows=2 frows=2 row0=sine row1=square buttons=2 detach=2; max: rows=1 frows=1 flag=1 row0=sine; back: rows=2 row0=sine row1=square flag=0
|
||||
[maximize-probe] corner: hidden_when_off=1 shown_when_near=1 other_row=0 detach_near=1 detach_other=0
|
||||
```
|
||||
|
||||
- `single_buttons=` is the toggle count *before* the second row is added, while the layout is
|
||||
the default one figure: **0** — one figure has nothing to enlarge *to*, so it carries no
|
||||
button (and `buttons=` is what the same count reads once the second row exists).
|
||||
`detach_single=` is the same for the **detach** buttons: **0** — detaching the only figure
|
||||
would leave the main window with nothing to draw — and `detach=2` once the second row exists
|
||||
(and **0** again on the `max:` line, where a maximized layout has hidden its rows).
|
||||
- the corner leg's `detach_near=1 detach_other=0` read the detach buttons with the same pointer
|
||||
the maximize toggle reads: both come out together at the corner, the other row's stay hidden.
|
||||
The pointer sits on the detach button's own centre — its zone is the strictest of the pair.
|
||||
- `before:` is the arrangement as set: two plots, two figure rows, the signals on their rows,
|
||||
and `buttons=` one overlay toggle per figure row (the toggles are on screen whether or not
|
||||
the layout editor is up).
|
||||
@@ -1453,19 +1462,76 @@ places), arranged as two single-cell rows with one signal each. The two reads af
|
||||
|
||||
`UDPSCOPE_PROBE_SCOPE_MS`. The **second window**: `open_scope_window()` twice — once copying
|
||||
the main window's figure 0, once empty — and read back a few frames later (the main tick is the
|
||||
scope's bind/acquire/repaint pump). Measured output:
|
||||
scope's bind/acquire/repaint pump). It can run beside `UDPSCOPE_ENABLE_ALL_MS`, but that probe's
|
||||
second leg clears the front plot through the signal page, so the main's slot 0 is empty by then
|
||||
and `copied` names the **first non-empty** copied slot instead (`'square'` in that order, `'sine'`
|
||||
alone). `copied=''` with every slot empty is a copy that moved nothing. Measured output:
|
||||
|
||||
```
|
||||
[scope-probe] loaded closed=2 scopes=0
|
||||
[scope-probe] count=2 copied='sine' bound=1 empty_ok=1 windows=1
|
||||
[scope-probe] drop=1 slot1='square' page_groups=2 empty_drop=1 empty_slot0='square'
|
||||
[scope-probe] detach=1 scopes=3 frows=1 attach=1 scopes_back=2 frows_back=2 slot='ramp'
|
||||
[scope-probe] gl copy=ok(67f) empty=ok(39f) empty_bound=1
|
||||
[scope-probe] gl detached ok=1 frames=42 rows=1 slot0='sine'
|
||||
[scope-probe] rclick=1
|
||||
[scope-probe] pin_mirror=1 main=[-1.000..1.000] scope=[-1.000..1.000] x=[-1.000..1.000]
|
||||
```
|
||||
|
||||
- `count=2` is both windows having been created and retained; `windows=1` each being a visible
|
||||
top-level window.
|
||||
- `copied='sine'` is the copy's first slot holding the main window's figure-0 signal, and
|
||||
- `copied='sine'` is the copy's first non-empty slot holding the main window's figure-0 signal
|
||||
(`'square'` when the enable-all probe ran first and cleared slot 0 first), and
|
||||
`bound=1` the tick having resolved it to a live channel — the scope is bound to the same
|
||||
model, not a snapshot taken at creation.
|
||||
- `empty_ok=1` is the `-1` call producing an empty row, which is the other entry point the
|
||||
overflow menu uses.
|
||||
- `loaded closed=N scopes=0` is the **session remembering windows without opening any**: the
|
||||
entries a previous run saved are in `App::closed_windows`, and no scope window exists at start.
|
||||
`scopes` above zero is a launch that opened windows on its own.
|
||||
- `reopen scopes=… remembered=… left=0` is **close → remember → the menu entry**: destroying a
|
||||
window puts its arrangement into `closed_windows` (by name -- `name=1` checks it), clicking the
|
||||
*Reopen closed windows* item ("clicked", the item's own handler) opens every remembered one, and
|
||||
the list is empty after (`left=0`). A `remembered` that did not grow is a destroy handler that
|
||||
forgot to record; `left` non-zero is an entry the menu did not take.
|
||||
- `instruments windows=N cursors=N gate=N trigger=N marks=N` is the **one owner, every window**:
|
||||
given a cursor pair, a gate band and the trigger marker on the main window, every scope window
|
||||
reports the same (each count is how many windows matched). `marks` is the per-window measurement
|
||||
mark count agreeing with what that window plots -- a window that plots the measured signal has
|
||||
its ruler, one that does not has none. All four counts equal to `windows` is the reading;
|
||||
`trigger` is compared against the **main** (the marker is up only while the trigger is), and the
|
||||
parenthesis reports the main's state so a zero is distinguishable from an unequal one. The leg
|
||||
adds a real gated `Top` measurement on `sine`, because the gate is restated visible every frame
|
||||
by `update_measures()` and `marks` only come from `show_on_plot` measurements.
|
||||
- The second line is the **window as a drop target and a *Plot on* group**: `drop=1` is
|
||||
`on_scope_drop_channel()` — the handler the window's own target wires — putting `square` in
|
||||
the copied window's second slot (`slot1=`), the rebuild and the area's re-point then left to
|
||||
the window's next frame; `page_groups=2` is the signal page of an unplotted signal carrying
|
||||
**one *Plot on* group per window** (`udpscope-scope-plot-on`), so a signal can be plotted
|
||||
into a scope window without dragging it there. `empty_drop=1 empty_slot0='square'` is the
|
||||
same drop onto the **empty** window — the user's own path, "open a scope window and drop a
|
||||
signal into it" — landing in its row set. `drop=0` names a refused drop (a full plot or an
|
||||
unacquirable signal); `page_groups` less than the window count is a page rebuilt before the
|
||||
windows were read or a group that lost its window.
|
||||
- `pin_mirror=1` is a scope window **mirroring a pin as a range**: the probe pins the main window
|
||||
and reads the scope's pin and its plot's x range a frame later. Both must agree and the x range
|
||||
must be ordered (`x=[min..max]`, `min < max`). A `0` with an inverted scope pin (`[+pre, +post]`)
|
||||
is the defect this exists for: `pin_event_window()` takes **spans**, and passing it a range's
|
||||
signed `min` turned the window inside out, so a scope in Held or Paused mode drew no trace and no
|
||||
x labels at all.
|
||||
- `layout buttons=3 rows=1->2` is the window's **own layout editor**: the toggle builds
|
||||
`2 × rows + 1` buttons (a `×` per row, a `+` per boundary, Done is not counted) for one figure,
|
||||
and emitting the first boundary `+` adds a row. A wrong count is a button set built for the main
|
||||
window's rows; `rows` unchanged is a handler writing the wrong row set.
|
||||
- `rclick=1` is the scope window's **own right-click menu** being wired (one click gesture with
|
||||
button 3 on its GL area, beside the drop target): the menu's actions act on that window's row
|
||||
set.
|
||||
- The fourth line is the windows' **GL account** (`gl copy=`/`gl empty=`): `ok(Nf)` is a context
|
||||
that staged N frames, `err:` carries the adapter's failure message. A scope whose context
|
||||
failed draws *nothing* — no axes, no traces, exactly the "blank plot widget" report — so
|
||||
`err:` there is a driver/context finding, not a layout one, and `empty_bound=1` (the dropped
|
||||
signal's channel, read a frame after the drop) with `ok(...)` together mean the window is
|
||||
drawing; a blank window under those two readings is a compositor problem, not the app's.
|
||||
|
||||
## `[search-probe]`
|
||||
|
||||
@@ -2051,8 +2117,9 @@ and `regained` reads 400 ms later.
|
||||
## `[source-kinds]`
|
||||
|
||||
```
|
||||
[source-kinds] kinds=udps,e1608,flatudp,probe
|
||||
[source-kinds] kinds=udps,tcps,e1608,flatudp,probe
|
||||
[source-kinds] form udps: round_trip=1 valid=1
|
||||
[source-kinds] form tcps: round_trip=1 valid=1
|
||||
[source-kinds] form e1608: round_trip=1 valid=1
|
||||
[source-kinds] form flatudp: round_trip=1 valid=1
|
||||
[source-kinds] form probe: round_trip=1 valid=1
|
||||
@@ -2066,10 +2133,10 @@ and `regained` reads 400 ms later.
|
||||
source-plugin path from the registry to the producer thread. The variable also registers the test-only `probe`
|
||||
kind, so a run without it never shows that kind. The `form` line appears once per registered kind.
|
||||
|
||||
- `kinds=udps,e1608,flatudp,probe`: a missing kind means `register_builtin_sources()` was not called before `parse_args()`,
|
||||
- `kinds=udps,tcps,e1608,flatudp,probe`: a missing kind means `register_builtin_sources()` was not called before `parse_args()`,
|
||||
or the env var gate is wrong.
|
||||
- `round_trip=0`: a form's `write` and `read` disagree about a key -- a renamed `SourceConfig` key, or an
|
||||
absent key that was not defaulted. `valid=0` is a default configuration the form refuses. The e1608 form's sample sets address, port, scan_rate, mode, ai1, trigger, dir1, plot0 and gen_rate; a mismatch names one of those. The flatudp sample sets port, bind, multicast, iface, rate, endian and signals.
|
||||
absent key that was not defaulted. `valid=0` is a default configuration the form refuses. The e1608 form's sample sets address, port, scan_rate, mode, ai1, trigger, dir1, plot0 and gen_rate; a mismatch names one of those. The flatudp sample sets port, bind, multicast, iface, rate, endian and signals. The tcps sample sets server, port and stream.
|
||||
- `panel disconnected: sensitive=0 present=1`: **disable, don't hide.** `present=0` is a panel that was hidden
|
||||
or never added; `sensitive=1` is a panel that stays live with no source behind it.
|
||||
- `panel connected: sensitive=1`: anything else means `apply_source_panels` acted on the wrong widget (it must
|
||||
@@ -2236,6 +2303,43 @@ same reason.
|
||||
- `resumed: ok=1`: `Connected` was raised again and frames resumed. `connected=0` here means the source never
|
||||
recovered after the sender came back.
|
||||
|
||||
## `[tcps]`
|
||||
|
||||
```
|
||||
[tcps] stream: connected=1 signals=5 names=time,sine,square,ramp,noise frames=373 ok=1
|
||||
[tcps] capture button: present=1 sensitive=0
|
||||
[tcps] reconnect: connected=1 frames_before=211 after=301 reconnects_before=0 after=1 ok=1
|
||||
```
|
||||
|
||||
`UDPSCOPE_PROBE_TCPS=1500` (the first two lines; counts vary) and `UDPSCOPE_PROBE_TCPS_RECONNECT=2000` (the last)
|
||||
run as `--source tcps --source-opt port=44500` against `tools/mock_tcpstreamer.py` with the options in
|
||||
`verification.md`. A service run (`--stream fast` on both sides) reads the same two lines; the mock logs
|
||||
`SUBSCRIBE 'fast'`. With no mock the stream line reads `connected=0 signals=0 names= frames=0 ok=0`: a reading,
|
||||
not a failure (the retries are quiet on stderr).
|
||||
A mock run with `--rate 1000000 --batch 20000` sends frames of about 560 KB; the stream line must still
|
||||
read `frames>0 ok=1`. `frames=0` with the mock logging a new client every few hundred ms is the signature
|
||||
of a receive buffer smaller than the server's frame (the client drops the connection, reconnects, and
|
||||
gets CONFIG again, never DATA).
|
||||
|
||||
- `stream: ok=1`: connected, the five names the mock sends (its `time` master channel is a signal of its own, so
|
||||
the list is `time,sine,square,ramp,noise`, not four), and frames counted. `connected=0` with a mock running is
|
||||
the connect or the `SUBSCRIBE` failing -- a mock started with `--stream` and an app without `stream=` (or a
|
||||
different name) is closed by the mock (at once on a wrong name, after 5 s on none) and the app reconnects
|
||||
forever; the mock's log says `no valid SUBSCRIBE`. `names` wrong or `signals=0` with `connected=1` is a CONFIG that was not decoded.
|
||||
- `capture button: present=1 sensitive=0`: the capture is of the UDP socket and a TCP source reports no
|
||||
datagrams (`has_datagrams()` false), so it is disabled, not hidden. `sensitive=1` is the button enabled for a
|
||||
kind that has nothing to capture.
|
||||
- `reconnect: ok=1`: after the mock closed the connection (`--close-after 3`) the client came back
|
||||
(`connected=1`), frames advanced past `frames_before` and the `reconnects` stat advanced past
|
||||
`reconnects_before`. The probe polls every 100 ms for up to 6 s, so it reads the first moment all three hold.
|
||||
`ok=0` is a client that did not come back after the close, or `Acquisition` not mapping `reconnects`;
|
||||
`reconnects_before=1` is the probe's baseline falling **after** the close (`UDPSCOPE_PROBE_TCPS_RECONNECT` must
|
||||
be earlier than the mock's `--close-after`).
|
||||
- **Not a defect:** after the reconnect the mock restarts its time base at 0 and the app prints repeating
|
||||
`warning: the clock stalled at ... samples stamped [...] are all behind it`. That is `Acquisition`'s behaviour
|
||||
for any stream whose clock goes backwards, identical to restarting the UDPS mock; the line is a reading, not a
|
||||
failure of this probe.
|
||||
|
||||
## `[prefs-probe]`
|
||||
|
||||
```
|
||||
|
||||
@@ -576,7 +576,7 @@ precedent, not an exception.
|
||||
- `UDPSCOPE_PROBE_SOURCE_KINDS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000`, run as `--source probe --source-opt rate=500`
|
||||
-- the source-plugin path end to end. The variable's presence also registers the test-only `probe` kind (a
|
||||
synthetic sine with one panel), so it is the only way a run sees that kind; its value is the delay in ms.
|
||||
Nine `[source-kinds]` lines (one `form` line per registered kind: `udps`, `e1608`, `flatudp`, `probe`); the
|
||||
Ten `[source-kinds]` lines (one `form` line per registered kind: `udps`, `tcps`, `e1608`, `flatudp`, `probe`); the
|
||||
reading's own entry is in `probe-readings.md`.
|
||||
- `UDPSCOPE_PROBE_E1608=1500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source e1608` against
|
||||
`tools/mock_e1608.py` -- the MCC E-1608 end to end: panels present and sensitive, a DAC write, a
|
||||
@@ -637,6 +637,22 @@ precedent, not an exception.
|
||||
- **no sender:** `connected=0` throughout, a reading and not a failure.
|
||||
- `UDPSCOPE_PROBE_FLATUDP_GAP=1500` -- starts the silence poll described above (2 s after the source's last
|
||||
datagram it must be disconnected; then it must reconnect and the frame count move on).
|
||||
- `UDPSCOPE_PROBE_TCPS=1500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source tcps --source-opt port=44500`
|
||||
against `tools/mock_tcpstreamer.py --port 44500` -- the TCPStreamer source end to end: the connection, the
|
||||
five channel names the mock sends (`time,sine,square,ramp,noise`), frames flowing and the capture button
|
||||
present but disabled. It enables the signals itself, so do **not** add `UDPSCOPE_ENABLE_ALL_MS`. Runs, each
|
||||
short and serial, with the mock killed afterwards:
|
||||
- **standalone:** the mock with no `--stream`, `UDPSCOPE_PROBE_TCPS=1500 UDPSCOPE_EXIT_AFTER_MS=4000`;
|
||||
- **service:** mock `--stream fast`, app `--source-opt stream=fast`, the same environment; the mock logs
|
||||
`SUBSCRIBE 'fast'`;
|
||||
- **reconnect:** mock `--close-after 3`, `UDPSCOPE_PROBE_TCPS=1500 UDPSCOPE_PROBE_TCPS_RECONNECT=2000
|
||||
UDPSCOPE_EXIT_AFTER_MS=8000`; stderr also carries `the clock stalled at ...` warnings after the
|
||||
reconnect (see `probe-readings.md`);
|
||||
- **no mock:** `connected=0` throughout, a reading and not a failure, with no retry flood on stderr.
|
||||
- `UDPSCOPE_PROBE_TCPS_RECONNECT=2000` -- records the frame and reconnect counts, then polls every 100 ms
|
||||
(up to 6 s) until connected, frames and reconnects have all advanced, and prints. **It must fall before the
|
||||
mock's `--close-after`** (the baseline is read before the close); the poll, not a fixed delay, absorbs
|
||||
when the close lands.
|
||||
- `UDPSCOPE_PROBE_SEARCH_MS` — runs a buffer search for the armed condition, cross-checks the live
|
||||
trigger's own `t0` against the nearest hit (`agree=1` is the load-bearing reading), and jumps to
|
||||
hit 0. Reports the resulting `mode=`, `axis=` and `marks placed=`.
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
# TCPStreamer Source Kind 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 `tcps` source kind that receives a MARTe2 `TCPStreamer` DataSource, standalone or through a `TCPStreamService`.
|
||||
|
||||
**Architecture:** `deps/udpsclient` gets an additive TCP transport mode (connect, optional SUBSCRIBE, framed reads reusing `read_tcp_frame`). `UdpsSource` takes a transport argument so the TCP kind reuses its callbacks. A new `tcps_kind.cpp` supplies the form and registration. The headless self-check gets a `[tcps]` probe against a new Python mock server.
|
||||
|
||||
**Tech Stack:** C99 (`deps/udpsclient`), C++17 + GTK4/libadwaita (`src/`), Python 3 + numpy (mock), CMake.
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-11-06-tcp-streamer-source-design.md`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- There is no unit test suite; verification is the app's own headless self-check under `xvfb-run` against a mock. Read `docs/claude/verification.md` (baselines) before Task 3 and run probes **short and serial**, killing the mock afterwards.
|
||||
- Baselines for every run: exactly **one** build warning (the pre-existing one; record the count before starting), and no `Gtk-CRITICAL`.
|
||||
- Every new source file starts with the two-line SPDX header: `// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari` (`#` comments in Python, after the shebang).
|
||||
- The GTK thread reaches a source only through `DataSource::post()`; `udps_*` names stay in `src/source/udps_*.cpp` and the new `tcps` files may include `udps_source.hpp` only.
|
||||
- One connection carries one stream; multiplexed `SUBSCRIBE` lists are out of scope.
|
||||
- Session naming for a non-UDPS kind is automatic (`session_host` -> `tcps-<identity>`, port 1, `source_kind=tcps`); do not touch `registry.cpp`'s session functions.
|
||||
- A kind id is `[a-z0-9]+`: `tcps`. Display name: `TCPStreamer`. Default port 44500.
|
||||
- Do not commit unless the task step says so.
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Change | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| `deps/udpsclient/udps_client.h` | modify | `use_tcp`, `stream_name` config fields |
|
||||
| `deps/udpsclient/udps_client.c` | modify | `connect_tcp()`, dispatch, no DISCONNECT frame in TCP mode, reorder window 0 |
|
||||
| `tools/mock_tcpstreamer.py` | create | Mock TCPStreamer / TCPStreamService server, reuses `mock_udpstreamer.py` frame builders |
|
||||
| `src/source/udps_source.hpp/.cpp` | modify | `Transport` enum, constructor argument, accessor |
|
||||
| `src/source/tcps_kind.cpp` | create | `tcps` `SourceKind` and its form |
|
||||
| `src/source/registry.hpp/.cpp` | modify | `make_tcps_kind()`, registration |
|
||||
| `CMakeLists.txt` | modify | add `tcps_kind.cpp` |
|
||||
| `src/ui_probe.cpp`, `src/ui.cpp` | modify | `[source-kinds]` sample, `[tcps]` probes and their env scheduling |
|
||||
| `README.md`, `docs/claude/*.md` | modify | document the kind, the upstream change, the probes |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: TCP transport in `deps/udpsclient`, and the mock server
|
||||
|
||||
**Files:**
|
||||
- Modify: `deps/udpsclient/udps_client.h` (struct `udps_client_config_t`, around line 263-282)
|
||||
- Modify: `deps/udpsclient/udps_client.c` (struct `udps_client` ~line 166, `send_control` ~1021, `do_connect` ~1076, `do_disconnect` ~1093, `udps_client_create` ~1260)
|
||||
- Create: `tools/mock_tcpstreamer.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `udps_client_config_t::use_tcp` (`int`, 0 = UDP as before) and `::stream_name` (`const char *`, NULL or empty = no SUBSCRIBE). Both default to zero/NULL through `udps_client_config_init()` (it memsets). `server_port` is the TCP port in TCP mode.
|
||||
- Produces: `tools/mock_tcpstreamer.py --port P [--stream NAME] [--rate R] [--batch B] [--close-after S] [--drop-every N]`.
|
||||
|
||||
- [ ] **Step 1: Add the config fields** at the end of `udps_client_config_t` in `udps_client.h`, after `reorder_window_s`:
|
||||
|
||||
```c
|
||||
/**
|
||||
* Non-zero selects the TCP transport (a MARTe2 TCPStreamer / TCPStreamService):
|
||||
* @c server_port is the TCP port, no CONNECT or DISCONNECT frame is sent, the
|
||||
* server streams CONFIG then DATA on connect, and the reorder window is forced
|
||||
* to 0 (TCP is ordered, so a counter gap is a server-side drop). @c multicast_group,
|
||||
* @c interface_addr and @c data_port are ignored.
|
||||
*/
|
||||
int use_tcp;
|
||||
/**
|
||||
* TCP mode only: when non-empty (1..255 bytes) a SUBSCRIBE frame naming this stream is
|
||||
* sent right after every connect, as a TCPStreamService requires. A plain
|
||||
* TCPStreamer ignores it. NULL or "" sends nothing.
|
||||
*/
|
||||
const char *stream_name;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Own a copy of the name.** In `struct udps_client`, after `char iface_addr[128];` add `char stream_name[256];`. In `udps_client_create`, next to the other `copy_str` calls add `copy_str(c->stream_name, sizeof c->stream_name, cfg->stream_name);` and after the `c->cfg.interface_addr = ...` line add:
|
||||
|
||||
```c
|
||||
c->cfg.stream_name = c->stream_name;
|
||||
if (c->cfg.use_tcp) {
|
||||
c->cfg.multicast_group = NULL;
|
||||
c->mcast_group[0] = '\0';
|
||||
c->cfg.reorder_window_s = 0.0;
|
||||
}
|
||||
```
|
||||
|
||||
Also reject a name over 255 bytes: before `copy_str`, `if (cfg->use_tcp && cfg->stream_name != NULL && strlen(cfg->stream_name) > 255u) { free(c); return NULL; }` (place right after the `calloc` succeeds).
|
||||
|
||||
- [ ] **Step 3: Add `connect_tcp()`** after `connect_multicast` (before `do_connect`):
|
||||
|
||||
```c
|
||||
/** TCP transport: connect, then SUBSCRIBE when a stream name is set. */
|
||||
static int connect_tcp(udps_client_t *c) {
|
||||
struct timeval tv;
|
||||
size_t name_len = strlen(c->stream_name);
|
||||
|
||||
c->tcp_fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (c->tcp_fd < 0) {
|
||||
return fail(c, "TCP socket() failed: %s", strerror(errno));
|
||||
}
|
||||
set_recv_buffer(c->tcp_fd, c->cfg.recv_buffer_bytes);
|
||||
/* Linux bounds a blocking connect() with SO_SNDTIMEO, so an unreachable host
|
||||
* cannot hold the polling thread for minutes. */
|
||||
tv.tv_sec = 3;
|
||||
tv.tv_usec = 0;
|
||||
(void)setsockopt(c->tcp_fd, SOL_SOCKET, SO_SNDTIMEO, &tv, (socklen_t)sizeof tv);
|
||||
if (connect(c->tcp_fd, (struct sockaddr *)&c->server_sa,
|
||||
(socklen_t)sizeof c->server_sa) != 0) {
|
||||
int e = errno;
|
||||
close_sockets(c);
|
||||
return fail(c, "TCP connect to %s:%u failed: %s", c->server_addr,
|
||||
(unsigned)c->cfg.server_port, strerror(e));
|
||||
}
|
||||
/* A half-received frame must not wedge the poll loop (read_exact_tcp blocks). */
|
||||
tv.tv_sec = 1;
|
||||
(void)setsockopt(c->tcp_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, (socklen_t)sizeof tv);
|
||||
|
||||
if (name_len > 0u) {
|
||||
uint8_t pkt[UDPS_HEADER_SIZE + 255u];
|
||||
build_header(pkt, UDPS_PKT_SUBSCRIBE, 0u, 0u, 1u, (uint32_t)name_len);
|
||||
memcpy(pkt + UDPS_HEADER_SIZE, c->stream_name, name_len);
|
||||
if (send(c->tcp_fd, pkt, UDPS_HEADER_SIZE + name_len, MSG_NOSIGNAL) !=
|
||||
(ssize_t)(UDPS_HEADER_SIZE + name_len)) {
|
||||
int e = errno;
|
||||
close_sockets(c);
|
||||
return fail(c, "SUBSCRIBE failed: %s", strerror(e));
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
Add `UDPS_PKT_SUBSCRIBE = 5 /**< Client -> server (TCP stream service): payload = stream name. */` to the packet-type enum in `udps_client.h` (add a comma after the `UDPS_PKT_DISCONNECT = 4` line).
|
||||
|
||||
- [ ] **Step 4: Dispatch and disconnect.** Replace the first lines of `do_connect`:
|
||||
|
||||
```c
|
||||
int rc = c->cfg.use_tcp ? connect_tcp(c)
|
||||
: (c->mcast_group[0] != '\0') ? connect_multicast(c)
|
||||
: connect_unicast(c);
|
||||
```
|
||||
|
||||
In `do_disconnect`, replace `(void)send_control(c, UDPS_PKT_DISCONNECT);` with `if (!c->cfg.use_tcp) { (void)send_control(c, UDPS_PKT_DISCONNECT); }` (a write to a peer that closed would raise SIGPIPE and kill the application; the server detects the close itself).
|
||||
|
||||
- [ ] **Step 5: Confirm the other TCP-path assumptions by reading, not guessing.** Read `deliver_payload` (~line 848) and confirm that with `reorder_window_s == 0` a counter jump delivers the frame with `lost > 0` and `stats.counter_gaps` incremented, and that `read_tcp_frame` -> `handle_packet` needs no change. If either is false, stop and report; do not patch around it.
|
||||
|
||||
- [ ] **Step 6: Write the mock** `tools/mock_tcpstreamer.py` (`chmod +x`):
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MIT
|
||||
# SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
"""A minimal MARTe2 TCPStreamer / TCPStreamService producer, for exercising udpscope.
|
||||
|
||||
Listens on `--port`, serves one client at a time with the same frames as
|
||||
mock_udpstreamer.py (CONFIG, then Accumulate DATA packets of sine/square/ramp/noise), each
|
||||
framed UDPS-over-TCP. Without `--stream` it behaves like a standalone TCPStreamer: the
|
||||
stream starts at once and anything the client sends is ignored. With `--stream NAME` it
|
||||
behaves like a TCPStreamService: the client must open with a SUBSCRIBE frame naming NAME
|
||||
within 5 s, otherwise the connection is closed.
|
||||
|
||||
./tools/mock_tcpstreamer.py --port 44500 --stream fast --close-after 6
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import select
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import mock_udpstreamer as m # noqa: E402
|
||||
|
||||
PKT_SUBSCRIBE = 5
|
||||
HEADER = 17
|
||||
|
||||
|
||||
def read_exact(conn, n, deadline):
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
left = deadline - time.monotonic()
|
||||
if left <= 0:
|
||||
return None
|
||||
r, _, _ = select.select([conn], [], [], left)
|
||||
if not r:
|
||||
return None
|
||||
chunk = conn.recv(n - len(buf))
|
||||
if not chunk:
|
||||
return None
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
def subscribed_to(conn, want):
|
||||
"""True once a SUBSCRIBE frame naming `want` arrived within 5 s."""
|
||||
deadline = time.monotonic() + 5.0
|
||||
head = read_exact(conn, HEADER, deadline)
|
||||
if head is None:
|
||||
return False
|
||||
magic, ptype, _, _, _, plen = struct.unpack("<IBIHHI", head)
|
||||
if magic != m.MAGIC or ptype != PKT_SUBSCRIBE or not 0 < plen <= 4096:
|
||||
return False
|
||||
name = read_exact(conn, plen, deadline)
|
||||
return name is not None and name.decode(errors="replace") == want
|
||||
|
||||
|
||||
def serve(conn, args):
|
||||
dt = 1.0 / args.rate
|
||||
period = args.batch / args.rate
|
||||
conn.sendall(m.build_config(args.rate, m.TIME_FULL_ARRAY, "V"))
|
||||
counter, t_stream = 1, 0.0
|
||||
started = time.monotonic()
|
||||
next_send = started
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
if args.close_after > 0 and now - started >= args.close_after:
|
||||
print("mock tcpstreamer: closing the connection (--close-after)", flush=True)
|
||||
return
|
||||
if now < next_send:
|
||||
time.sleep(min(next_send - now, 0.002))
|
||||
continue
|
||||
pkt = m.build_data(counter, t_stream, dt, args.batch, args.freq)
|
||||
if not (args.drop_every > 0 and counter % args.drop_every == 0):
|
||||
conn.sendall(pkt)
|
||||
counter += 1
|
||||
t_stream += args.batch * dt
|
||||
next_send += period
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", type=int, default=44500)
|
||||
ap.add_argument("--stream", default="", help="TCPStreamService mode: require SUBSCRIBE of this name")
|
||||
ap.add_argument("--rate", type=float, default=10000.0, help="samples per second")
|
||||
ap.add_argument("--batch", type=int, default=100, help="samples per packet")
|
||||
ap.add_argument("--freq", type=float, default=5.0, help="waveform frequency, Hz")
|
||||
ap.add_argument("--close-after", type=float, default=0.0, metavar="S",
|
||||
help="close each connection after S seconds (the client must reconnect); 0 = never")
|
||||
ap.add_argument("--drop-every", type=int, default=0, metavar="N",
|
||||
help="skip sending every Nth packet but advance the counter (a server-side drop); 0 = never")
|
||||
args = ap.parse_args()
|
||||
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(("0.0.0.0", args.port))
|
||||
srv.listen(4)
|
||||
print("mock tcpstreamer: listening on :%d (%s)" % (
|
||||
args.port, "service stream '%s'" % args.stream if args.stream else "standalone"), flush=True)
|
||||
while True:
|
||||
conn, addr = srv.accept()
|
||||
print("mock tcpstreamer: client %s:%d" % addr, flush=True)
|
||||
try:
|
||||
if args.stream and not subscribed_to(conn, args.stream):
|
||||
print("mock tcpstreamer: no valid SUBSCRIBE for '%s'; closing" % args.stream, flush=True)
|
||||
continue
|
||||
if args.stream:
|
||||
print("mock tcpstreamer: SUBSCRIBE '%s'" % args.stream, flush=True)
|
||||
serve(conn, args)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
print("mock tcpstreamer: client went away", flush=True)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Build the dependency and the app** with the project's usual command (see `docs/claude/build-and-run.md`; normally `cmake --build build -j`). Expected: it builds; the warning count equals the count recorded before the task. Run `python3 tools/mock_tcpstreamer.py --help` and `python3 -c "import ast,sys;ast.parse(open('tools/mock_tcpstreamer.py').read())"`: both succeed.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add deps/udpsclient/udps_client.h deps/udpsclient/udps_client.c tools/mock_tcpstreamer.py
|
||||
git commit -m "udpsclient: a TCP transport mode (SUBSCRIBE, no CONNECT) and a TCP mock server"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: The `tcps` source kind
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/source/udps_source.hpp`, `src/source/udps_source.cpp`
|
||||
- Create: `src/source/tcps_kind.cpp`
|
||||
- Modify: `src/source/registry.hpp`, `src/source/registry.cpp`, `CMakeLists.txt` (source list near line 242-243)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `udps_client_config_t::use_tcp` / `::stream_name` from Task 1.
|
||||
- Produces: `enum class UdpsSource::Transport { Udp, Tcp }`; `UdpsSource(const SourceConfig&, Transport = Transport::Udp)`; `Transport UdpsSource::transport() const`; `std::unique_ptr<SourceKind> make_tcps_kind()`; kind id `tcps` with config keys `server` (default `127.0.0.1`), `port` (default `44500`), `stream` (optional).
|
||||
|
||||
- [ ] **Step 1: `udps_source.hpp`.** Inside `class UdpsSource`, add before the constructor:
|
||||
|
||||
```cpp
|
||||
enum class Transport { Udp, Tcp };
|
||||
```
|
||||
|
||||
change the constructor to `explicit UdpsSource(const SourceConfig& cfg, Transport transport = Transport::Udp);`, add `Transport transport() const { return transport_; }` in the public section, and members `Transport transport_ = Transport::Udp;` and `std::string stream_;`.
|
||||
|
||||
- [ ] **Step 2: `udps_source.cpp`.** Replace the constructor:
|
||||
|
||||
```cpp
|
||||
UdpsSource::UdpsSource(const SourceConfig& cfg, Transport transport)
|
||||
: transport_(transport),
|
||||
server_(cfg.get("server", "127.0.0.1")),
|
||||
port_(static_cast<std::uint16_t>(
|
||||
cfg.get_int("port", transport == Transport::Tcp ? 44500 : 8000))),
|
||||
multicast_(transport == Transport::Tcp ? std::string() : cfg.get("multicast")),
|
||||
interface_(transport == Transport::Tcp ? std::string() : cfg.get("interface")),
|
||||
stream_(transport == Transport::Tcp ? cfg.get("stream") : std::string()),
|
||||
recv_buffer_(static_cast<int>(cfg.get_int("recv_buffer", 8 * 1024 * 1024))) {}
|
||||
```
|
||||
|
||||
Member declaration order must match the initialiser order: in the header declare `transport_` before `server_`, and `stream_` after `interface_` and before `recv_buffer_` (an out-of-order initialiser is a `-Wreorder` warning, which breaks the one-warning baseline). In `open()`, after `c.keepalive_interval_s = 1.0;` add:
|
||||
|
||||
```cpp
|
||||
if (transport_ == Transport::Tcp) {
|
||||
c.use_tcp = 1;
|
||||
c.stream_name = stream_.empty() ? nullptr : stream_.c_str();
|
||||
}
|
||||
```
|
||||
|
||||
`datagram_cb` registration stays; in TCP mode the client never fires it. Leave `set_datagram_tap` as is.
|
||||
|
||||
- [ ] **Step 3: Create `src/source/tcps_kind.cpp`.** Copy the helper block of `src/source/udps_kind.cpp` (`ChangeCb`, `entry_row`, `on_edited`, `on_value`, `hook_value`, `hook`, `text_of`; they are in an anonymous namespace, so the copy is local, as three kinds already do) and add the kind:
|
||||
|
||||
```cpp
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "source/registry.hpp"
|
||||
#include "source/udps_source.hpp"
|
||||
|
||||
#include <adwaita.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
/* ChangeCb, entry_row, on_edited, on_value, hook_value, hook, text_of:
|
||||
the same six helpers as udps_kind.cpp, verbatim. */
|
||||
|
||||
class TcpsKind final : public SourceKind {
|
||||
public:
|
||||
std::string id() const override { return "tcps"; }
|
||||
std::string display_name() const override { return "TCPStreamer"; }
|
||||
|
||||
std::string identity(const SourceConfig& cfg) const override {
|
||||
std::string s = cfg.get("server", "127.0.0.1") + ":" + std::to_string(cfg.get_int("port", 44500));
|
||||
if (!cfg.get("stream").empty()) s += "/" + cfg.get("stream");
|
||||
return s;
|
||||
}
|
||||
|
||||
std::unique_ptr<DataSource> make(const SourceConfig& cfg) const override {
|
||||
return std::make_unique<UdpsSource>(cfg, UdpsSource::Transport::Tcp);
|
||||
}
|
||||
|
||||
ConfigForm make_config_form() const override {
|
||||
GtkWidget* server = entry_row("_Server");
|
||||
GtkWidget* port = GTK_WIDGET(adw_spin_row_new_with_range(1, 65535, 1));
|
||||
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(port), "_Port");
|
||||
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(port), TRUE);
|
||||
adw_spin_row_set_value(ADW_SPIN_ROW(port), 44500);
|
||||
GtkWidget* stream = entry_row("S_tream name");
|
||||
|
||||
ChangeCb cb = std::make_shared<std::function<void()>>();
|
||||
hook(server, cb);
|
||||
hook_value(port, cb);
|
||||
hook(stream, cb);
|
||||
|
||||
ConfigForm f;
|
||||
f.rows = {server, port, stream};
|
||||
f.description =
|
||||
"Leave the stream name empty for a standalone TCPStreamer; name the stream for a TCPStreamService";
|
||||
f.focus = server;
|
||||
f.write = [=](const SourceConfig& c) {
|
||||
gtk_editable_set_text(GTK_EDITABLE(server), c.get("server", "127.0.0.1").c_str());
|
||||
adw_spin_row_set_value(ADW_SPIN_ROW(port), static_cast<double>(c.get_int("port", 44500)));
|
||||
gtk_editable_set_text(GTK_EDITABLE(stream), c.get("stream").c_str());
|
||||
};
|
||||
f.read = [=]() {
|
||||
SourceConfig c;
|
||||
c.set("server", text_of(server));
|
||||
c.set("port", std::to_string(static_cast<long>(adw_spin_row_get_value(ADW_SPIN_ROW(port)))));
|
||||
if (!text_of(stream).empty()) c.set("stream", text_of(stream));
|
||||
return c;
|
||||
};
|
||||
// A stream name is 1..255 bytes and, being the SUBSCRIBE payload, may not hold a comma
|
||||
// (that would select a multiplexed list, which this kind does not support).
|
||||
f.valid = [=]() {
|
||||
const std::string s = text_of(stream);
|
||||
return !text_of(server).empty() && s.size() <= 255 && s.find(',') == std::string::npos;
|
||||
};
|
||||
f.on_change = [cb](std::function<void()> fn) { *cb = std::move(fn); };
|
||||
return f;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<SourceKind> make_tcps_kind() { return std::make_unique<TcpsKind>(); }
|
||||
|
||||
} // namespace udpscope
|
||||
```
|
||||
|
||||
When writing the file, paste the six real helper definitions in place of the comment block. Check `udps_kind.cpp` includes `<functional>` transitively (it does through `source_kind.hpp`).
|
||||
|
||||
- [ ] **Step 4: Register.** `registry.hpp`: add `std::unique_ptr<SourceKind> make_tcps_kind();` after `make_udps_kind()` and add `tcps_kind.cpp` to the comment's file list. `registry.cpp`: in `register_builtin_sources()` add `register_kind(make_tcps_kind());` right after `make_udps_kind()` (so the chooser lists the two network kinds together). `CMakeLists.txt`: add `src/source/tcps_kind.cpp` after `src/source/udps_kind.cpp`.
|
||||
|
||||
- [ ] **Step 5: Build.** `cmake --build build -j`. Expected: builds, warning count unchanged from the baseline.
|
||||
|
||||
- [ ] **Step 6: Smoke-run against the mock** (serial, kill the mock after):
|
||||
|
||||
```bash
|
||||
python3 tools/mock_tcpstreamer.py --port 44500 &
|
||||
MOCK=$!
|
||||
UDPSCOPE_EXIT_AFTER_MS=4000 xvfb-run -a build/udpscope --source tcps --source-opt server=127.0.0.1 --source-opt port=44500 2>&1 | tail -20
|
||||
kill $MOCK
|
||||
```
|
||||
|
||||
Expected: no `Gtk-CRITICAL`; the mock prints `client 127.0.0.1:...`. (If the app's CLI spells the options differently, take the spelling from `docs/claude/build-and-run.md`.) Then the same with `--stream fast` on the mock and `--source-opt stream=fast` on the app: the mock prints `SUBSCRIBE 'fast'`. Then once with the wrong name (`stream=slow`): the mock prints `no valid SUBSCRIBE`, and the app keeps retrying without a stderr flood.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/source/udps_source.hpp src/source/udps_source.cpp src/source/tcps_kind.cpp src/source/registry.hpp src/source/registry.cpp CMakeLists.txt
|
||||
git commit -m "sources: a TCPStreamer kind (standalone or TCPStreamService) on the UDPS client's TCP mode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Probes
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ui_probe.cpp` (sample at ~line 2683 `const auto sample`; new `[tcps]` functions after `flatudp_probe`, ~line 3703), `src/ui.cpp` (env scheduling after the `UDPSCOPE_PROBE_FLATUDP_GAP` block, ~line 7657)
|
||||
- Declarations: wherever `flatudp_probe` / `flatudp_probe_gap` are declared (grep `flatudp_probe_gap` in `src/*.hpp`; add the two new functions beside them)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `UdpsSource::transport()`, kind `tcps`, `a->model->stats()` fields `connected`, `frames`, `reconnects`, `a->model->signals()`, `a->capture_button`.
|
||||
- Produces: env vars `UDPSCOPE_PROBE_TCPS=<ms>` and `UDPSCOPE_PROBE_TCPS_RECONNECT=<ms>`; stderr lines `[tcps] stream:`, `[tcps] capture button:`, `[tcps] reconnect:`; a `[source-kinds] form tcps:` line.
|
||||
|
||||
- [ ] **Step 1: Form round trip.** In `source_kinds_probe`'s `sample` lambda add a branch:
|
||||
|
||||
```cpp
|
||||
} else if (id == "tcps") {
|
||||
c.set("server", "10.0.0.7");
|
||||
c.set("port", "44501");
|
||||
c.set("stream", "fast");
|
||||
```
|
||||
|
||||
Build and run `UDPSCOPE_PROBE_SOURCE_KINDS=2000 UDPSCOPE_EXIT_AFTER_MS=5000 xvfb-run -a build/udpscope --source probe --source-opt rate=500 2>&1 | grep source-kinds`. Expected: `kinds=udps,tcps,e1608,flatudp,probe` and `form tcps: round_trip=1 valid=1`. The `kinds=` line changes for every run from now on, so update `docs/claude/probe-readings.md` (~line 2120-2123, 2135) and `docs/claude/verification.md` (~line 579, "Nine `[source-kinds]` lines" -> ten, "one `form` line per registered kind: `udps`, `tcps`, ...") in Task 4.
|
||||
|
||||
- [ ] **Step 2: Add the source accessor and stream probe** in `ui_probe.cpp`, after `flatudp_probe`:
|
||||
|
||||
```cpp
|
||||
namespace {
|
||||
|
||||
UdpsSource* tcps_source(App* a) {
|
||||
UdpsSource* s = a->acq != nullptr ? dynamic_cast<UdpsSource*>(a->acq->source()) : nullptr;
|
||||
return s != nullptr && s->transport() == UdpsSource::Transport::Tcp ? s : nullptr;
|
||||
}
|
||||
|
||||
std::uint64_t g_tcps_frames_before = 0;
|
||||
std::uint64_t g_tcps_reconnects_before = 0;
|
||||
|
||||
} // namespace
|
||||
|
||||
/// Phase 2, 1 s after the signals were enabled: connected, the mock's four channels, frames
|
||||
/// flowing, and the capture button present but disabled (the capture is of the UDP socket).
|
||||
gboolean tcps_probe_report(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
|
||||
std::string names;
|
||||
for (const SignalInfo& s : a->model->signals()) names += (names.empty() ? "" : ",") + s.name;
|
||||
const std::uint64_t frames = a->model->stats().frames.load(std::memory_order_relaxed);
|
||||
const bool ok = up && names == "sine,square,ramp,noise" && frames > 0;
|
||||
std::fprintf(stderr,
|
||||
"[tcps] stream: connected=%d signals=%zu names=%s frames=%" G_GUINT64_FORMAT " ok=%d\n",
|
||||
up ? 1 : 0, a->model->signals().size(), names.c_str(), frames, ok ? 1 : 0);
|
||||
std::fprintf(stderr, "[tcps] 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::fflush(stderr);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/// Phase 1: enable every signal in one structural change, report a second later.
|
||||
gboolean tcps_probe(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||
if (tcps_source(a) == nullptr) {
|
||||
std::fprintf(stderr, "[tcps] no tcps source (run with --source tcps)\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);
|
||||
g_timeout_add(1000, tcps_probe_report, a);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/// After the mock closed the connection (`--close-after`): the client must have reconnected and
|
||||
/// the frame count moved on. Phase 2 reads 1.5 s after phase 1 recorded the baseline.
|
||||
gboolean tcps_probe_reconnect_report(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
|
||||
const std::uint64_t frames = a->model->stats().frames.load(std::memory_order_relaxed);
|
||||
const std::uint64_t rc = a->model->stats().reconnects.load(std::memory_order_relaxed);
|
||||
const bool ok = up && frames > g_tcps_frames_before && rc > g_tcps_reconnects_before;
|
||||
std::fprintf(stderr,
|
||||
"[tcps] reconnect: connected=%d frames_before=%" G_GUINT64_FORMAT " after=%" G_GUINT64_FORMAT
|
||||
" reconnects_before=%" G_GUINT64_FORMAT " after=%" G_GUINT64_FORMAT " ok=%d\n",
|
||||
up ? 1 : 0, g_tcps_frames_before, frames, g_tcps_reconnects_before, rc, ok ? 1 : 0);
|
||||
std::fflush(stderr);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
gboolean tcps_probe_reconnect(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||
g_tcps_frames_before = a->model->stats().frames.load(std::memory_order_relaxed);
|
||||
g_tcps_reconnects_before = a->model->stats().reconnects.load(std::memory_order_relaxed);
|
||||
g_timeout_add(1500, tcps_probe_reconnect_report, a);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
```
|
||||
|
||||
Make sure `ui_probe.cpp` includes `source/udps_source.hpp` (add it beside the flatudp includes at line 11-12 if absent). The `reconnects` stat is fed from `SourceStats::reconnects` by `Acquisition`; if after Task 3 Step 4 it reads 0 while the client demonstrably reconnected, `Acquisition` does not map it for this path: report that rather than weakening the check.
|
||||
|
||||
- [ ] **Step 3: Schedule and declare.** Add the two declarations next to `flatudp_probe_gap`'s, and in `ui.cpp` after the `UDPSCOPE_PROBE_FLATUDP_GAP` block:
|
||||
|
||||
```cpp
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_TCPS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), tcps_probe, &app);
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_TCPS_RECONNECT")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), tcps_probe_reconnect, &app);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the three scenarios**, each short and serial, mock killed after each, stderr filtered with `grep -E '^\[tcps\]|Gtk-CRITICAL|warning'`:
|
||||
|
||||
1. Standalone: mock `--port 44500`; app `--source tcps --source-opt port=44500`, `UDPSCOPE_PROBE_TCPS=2500 UDPSCOPE_EXIT_AFTER_MS=5000`. Expected `[tcps] stream: connected=1 signals=4 names=sine,square,ramp,noise frames=<n> ok=1` (n > 0) and `[tcps] capture button: present=1 sensitive=0`.
|
||||
2. Service: mock `--port 44500 --stream fast`; app additionally `--source-opt stream=fast`. Same expectations.
|
||||
3. Reconnect: mock `--port 44500 --close-after 3`; app `UDPSCOPE_PROBE_TCPS=2500 UDPSCOPE_PROBE_TCPS_RECONNECT=4500 UDPSCOPE_EXIT_AFTER_MS=8000`. Expected `[tcps] reconnect: connected=1 ... ok=1`.
|
||||
|
||||
Also check the no-server case: no mock, `UDPSCOPE_PROBE_TCPS=2500`: `connected=0 ... ok=0` is a reading, not a crash; no stderr flood (a retry every 0.5 s is acceptable, more than a few lines per second is not).
|
||||
|
||||
- [ ] **Step 5: Baselines.** A full rebuild shows the same warning count as before the plan, and none of the runs printed `Gtk-CRITICAL`. Then re-run the source-kinds probe (Step 1) and confirm the existing `udps`/`e1608`/`flatudp`/`probe` form lines still read `round_trip=1 valid=1`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ui_probe.cpp src/ui.cpp
|
||||
git commit -m "probes: the TCPStreamer kind end to end (standalone, service, reconnect)"
|
||||
```
|
||||
|
||||
(Include the header that carries the probe declarations in `git add` if Step 3 touched one.)
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`, `docs/claude/architecture.md`, `docs/claude/build-and-run.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `CLAUDE.md` (only its intro sentence if it enumerates the source kinds)
|
||||
|
||||
- [ ] **Step 1: `docs/claude/build-and-run.md`.** The `deps/udpsclient` section says "Five things were changed". Make it six and add item 6: "**A TCP transport mode.** `udps_client_config_t::use_tcp` / `stream_name` select a TCP connection to a MARTe2 `TCPStreamer` or `TCPStreamService`: `connect_tcp()` (3 s connect bound via `SO_SNDTIMEO`, 1 s receive bound, an optional `SUBSCRIBE` frame), the existing `read_tcp_frame()` for the frames, the reorder window forced to 0, no DISCONNECT frame on close (a write to a closed peer would raise SIGPIPE), and no datagram tap. A frame must fit `UDPS_MAX_DATAGRAM_BYTES` (the server's `MaxPayloadSize` default of 60000 does)." Also update the earlier sentence "`deps/udpsclient` gained one additive entry point" to mention the TCP mode.
|
||||
|
||||
- [ ] **Step 2: `docs/claude/architecture.md` (Sources section).** Add a bullet: "**The TCPStreamer kind (`tcps`) is `UdpsSource` with `Transport::Tcp`** ... `src/source/tcps_kind.cpp` is only the form and identity (`host:port` or `host:port/stream`); there is no new `DataSource`. One stream per connection (no multiplexed `SUBSCRIBE` list: a source is single-timebase; merge on the server with `TCPStreamJoin`). `has_datagrams()` is false, so the capture button stays disabled. `Disconnected` follows the client's 2 s silence timeout or a closed connection, and it reconnects every 0.5 s. A stream whose frames are further apart than 2 s therefore reconnects continually, as UDPS does." Fix the bullet that says `UdpsSource` "wraps `deps/udpsclient` unchanged".
|
||||
|
||||
- [ ] **Step 3: `docs/claude/verification.md` and `probe-readings.md`.** Verification: update "Nine `[source-kinds]` lines (... `udps`, `e1608`, `flatudp`, `probe`)" to include `tcps`; add `UDPSCOPE_PROBE_TCPS` and `UDPSCOPE_PROBE_TCPS_RECONNECT` entries beside the FLATUDP ones, with the three scenarios from Task 3 Step 4 (commands, mock options, exit times). Probe readings: update `kinds=` (line ~2120 and ~2135) to `udps,tcps,e1608,flatudp,probe`, add `[source-kinds] form tcps: round_trip=1 valid=1` (and the form sample's keys: server, port, stream), and add a `## [tcps]` section after `[flatudp]` with the sample output of the three lines and what a wrong reading means (`connected=0` with a mock running: SUBSCRIBE or connect failing; `names` wrong: CONFIG not decoded; `sensitive=1`: the capture button was enabled for a kind with no datagrams; `reconnect ... ok=0`: the client did not come back after the close, or `Acquisition` does not map `reconnects`).
|
||||
|
||||
- [ ] **Step 4: `README.md`.** Wherever it lists the source kinds or the command-line `--source` values, add `tcps` with its options (`server`, `port` default 44500, `stream` optional), one line saying it receives a MARTe2 `TCPStreamer`/`TCPStreamService`, and under known limitations: single stream per connection, no packet capture over TCP, frames up to 65 KB (the server's `MaxPayloadSize`). If `CLAUDE.md`'s intro sentence enumerates the kinds, add `tcps` there too.
|
||||
|
||||
- [ ] **Step 5: Read-through.** `grep -rn "Nine \`\[source-kinds\]\|Five things\|unchanged" README.md CLAUDE.md docs/claude` and fix every statement the change made false.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md CLAUDE.md docs/claude
|
||||
git commit -m "docs: the TCPStreamer source kind, the client's TCP mode, and its probes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review (against the spec)
|
||||
|
||||
- Wire/scope (single stream, SUBSCRIBE only with a name): Task 1 Steps 1-4, Task 2 Step 3 (`valid` rejects a comma).
|
||||
- `udpsclient` TCP mode (use_tcp, stream_name, connect_tcp, reorder 0, no tap, additive, recorded as a sixth change): Task 1; recording: Task 4 Step 1.
|
||||
- Source adapter reuse via transport argument; form rows, description, identity, session naming (automatic), registration and CMake: Task 2.
|
||||
- Verification (fake server, probe lines for signals/frames/reconnect, baselines, real smoke test): Task 3 uses a Python mock rather than the in-process fake server the spec named, because the existing probes all use external mocks under `tools/`. A smoke test against a real MARTe `TCPStreamer` is not in the plan; do it only if one is runnable.
|
||||
- Docs: Task 4.
|
||||
- Types: `UdpsSource::Transport`, `transport()`, `make_tcps_kind()`, `tcps_source()`, env names are used identically across tasks.
|
||||
@@ -0,0 +1,51 @@
|
||||
# TCPStreamer source kind
|
||||
|
||||
A new source kind `tcps` ("TCPStreamer") that receives a MARTe2 `TCPStreamer` DataSource, either
|
||||
standalone or through a shared `TCPStreamService`.
|
||||
|
||||
## Wire and scope
|
||||
|
||||
- Both transports are plain UDPS framing over TCP. A standalone `TCPStreamer` (TCPSServer) sends
|
||||
CONFIG then DATA on connect. A `TCPStreamService` additionally requires a first client frame,
|
||||
`SUBSCRIBE` (type 5, payload = stream name, 1..255 bytes).
|
||||
- A plain server ignores `SUBSCRIBE`, so the client sends it only when a stream name is set.
|
||||
- One connection carries one stream. Multiplexed lists (`"a,b,c"`, stream index in `fragmentIdx`)
|
||||
are out of scope: a udpscope source is single-timebase. Several streams are merged server-side
|
||||
with `TCPStreamJoin`, which publishes an ordinary named stream.
|
||||
|
||||
## `deps/udpsclient`: a TCP transport mode
|
||||
|
||||
- `udps_client_config_t` gains `use_tcp` and `stream_name`.
|
||||
- New `connect_tcp()`: open the TCP socket, send `SUBSCRIBE` if a stream name is set, send no
|
||||
`CONNECT`. No UDP socket, no keepalive ACK.
|
||||
- `udps_client_poll` reuses `read_tcp_frame` / `handle_packet`.
|
||||
- `reorder_window_s` is forced to 0 in TCP mode: TCP is ordered, so a counter gap is a server-side
|
||||
drop and is reported at once.
|
||||
- Silence timeout and reconnect delay behave as in UDP (dead server, or a stalled client the server
|
||||
evicted, reconnects).
|
||||
- The datagram tap does not fire; the packet capture stays UDP-only (`has_datagrams()` false).
|
||||
- Additive. Recorded in `docs/claude/build-and-run.md` as a sixth intentional upstream change.
|
||||
|
||||
## Source adapter and kind
|
||||
|
||||
- `UdpsSource` takes a transport argument (`UdpsSource(cfg, Transport::Tcp)`) instead of being
|
||||
copied: its callbacks are identical. The TCP variant reads `server`, `port` (default 44500) and
|
||||
`stream`.
|
||||
- `src/source/tcps_kind.cpp`: kind `tcps`, display name "TCPStreamer". Form rows: Server, Port,
|
||||
Stream name (optional). Description: "Leave empty for a standalone TCPStreamer; name the stream
|
||||
for a TCPStreamService".
|
||||
- Identity: `host:port` or `host:port/stream`. Session naming follows the existing non-UDPS rule
|
||||
(`tcps-<identity>`, port 1, `source_kind=tcps`, `source_opt_*`).
|
||||
- Registered in `register_builtin_sources()` and `CMakeLists.txt`.
|
||||
|
||||
## Verification
|
||||
|
||||
No unit suite. Extend the self-check in `src/ui_probe.cpp` with an in-process fake TCP server that
|
||||
sends CONFIG then DATA, and requires `SUBSCRIBE` in one mode. Probe lines assert the signals, the
|
||||
frame count, and the reconnect after the server closes. Baselines: exactly one build warning, no
|
||||
`Gtk-CRITICAL`. A smoke test against a real MARTe `TCPStreamer` if one is runnable.
|
||||
|
||||
## Docs
|
||||
|
||||
README and `docs/claude/architecture.md` (Sources) describe the kind; `docs/claude/probe-readings.md`
|
||||
gets the new probe lines.
|
||||
@@ -77,12 +77,18 @@
|
||||
nothing is saved, nothing is dropped, and the rows' traces, colours and cursors come back
|
||||
where they were. The plot's right-click menu offers the same as <gui>Full screen this
|
||||
figure</gui> (and <gui>Restore the layout</gui> while one is enlarged). A layout of a single
|
||||
figure has nothing to enlarge *to*, so it carries no button.</p></item>
|
||||
figure has nothing to enlarge *to*, so it carries no button.</p></item> Beside the toggle, shown the same way, a <gui>detach</gui> button moves the figure into a
|
||||
window of its own — it leaves the main window's rows; the detached window carries an
|
||||
<gui>attach</gui> button in the same corner, which returns the figure to the main window and
|
||||
closes the window.
|
||||
<item><p>A plot's right-click menu has <gui>Open this plot in a new window</gui>: a second
|
||||
window showing the same stream through a copy of that figure (its cells, their widths and
|
||||
every trace), with its own axes following the main window's time window. The overflow menu's
|
||||
<gui>New scope window</gui> opens the same kind of window with an empty figure. Closing one
|
||||
closes that view only; the main window keeps acquiring.</p></item>
|
||||
<gui>New scope window</gui> opens the same kind of window with an empty figure. A signal
|
||||
badge dragged onto a window's plot lands in that window (on the plot under the pointer), and
|
||||
the signal page's <gui>Plot on</gui> section lists every window — one named group per
|
||||
window, one button per plot of it. Closing one closes that view only; the main window keeps
|
||||
acquiring.</p></item>
|
||||
</list>
|
||||
</section>
|
||||
|
||||
|
||||
+53
-3
@@ -448,7 +448,8 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
|
||||
std::string line;
|
||||
// What the last `[[...]]` header opened, so keys land in the right record.
|
||||
enum class Section { Top, Plot, Calibration, Unit, Color, Math, Measure } section = Section::Top;
|
||||
enum class Section { Top, Plot, Window, WindowPlot, Calibration, Unit, Color, Math, Measure } section =
|
||||
Section::Top;
|
||||
int plot_count = 0;
|
||||
bool plot_section_seen = false;
|
||||
while (std::getline(in, line)) {
|
||||
@@ -459,6 +460,14 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
section = Section::Plot;
|
||||
plot_section_seen = true;
|
||||
st.rows.emplace_back();
|
||||
} else if (t == "[[window]]") {
|
||||
section = Section::Window;
|
||||
st.windows.emplace_back();
|
||||
} else if (t == "[[window.plot]]") {
|
||||
// A window's own plot rows: the same shape as the main layout's `[[plot]]`, one level in.
|
||||
if (st.windows.empty()) st.windows.emplace_back();
|
||||
section = Section::WindowPlot;
|
||||
st.windows.back().rows.emplace_back();
|
||||
} else if (t == "[[calibration]]") {
|
||||
section = Section::Calibration;
|
||||
st.calibration.emplace_back();
|
||||
@@ -608,6 +617,35 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
} else if (section == Section::Window && !st.windows.empty()) {
|
||||
if (key == "name") {
|
||||
st.windows.back().name = strip_quote(value);
|
||||
} else if (key == "detached") {
|
||||
st.windows.back().detached = value == "true";
|
||||
}
|
||||
} else if ((section == Section::WindowPlot && !st.windows.empty() &&
|
||||
!st.windows.back().rows.empty())) {
|
||||
// The window's rows take exactly the main layout's keys; `rows` is reached through the
|
||||
// window record so the parser stays one shape.
|
||||
LayoutState::Row& row = st.windows.back().rows.back();
|
||||
if (key == "signals") {
|
||||
std::vector<std::string> names;
|
||||
if (value.size() >= 2 && value.front() == '[' && value.back() == ']') {
|
||||
parse_string_array(value.substr(1, value.size() - 2), names);
|
||||
}
|
||||
row.signals = names;
|
||||
} else if (key == "weight") {
|
||||
row.weight = std::strtod(value.c_str(), nullptr);
|
||||
} else if (key == "row") {
|
||||
try {
|
||||
const int r = std::stoi(value);
|
||||
if (r >= 0) row.row = r;
|
||||
} catch (...) {
|
||||
}
|
||||
} else if (key == "width") {
|
||||
const double w = std::strtod(value.c_str(), nullptr);
|
||||
if (std::isfinite(w) && w > 0.0) row.width = w;
|
||||
}
|
||||
} else if (section == Section::Plot && !st.rows.empty()) {
|
||||
if (key == "signals") {
|
||||
std::vector<std::string> names;
|
||||
@@ -802,8 +840,7 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
||||
if (state.buffer_samples > 0) o << "buffer_samples = " << state.buffer_samples << "\n";
|
||||
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
|
||||
if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\n";
|
||||
for (const auto& row : state.rows) {
|
||||
o << "\n[[plot]]\n";
|
||||
const auto write_row = [&](const LayoutState::Row& row) {
|
||||
// A row of -1 is a state built without the split (nothing in the app does that now); leaving the
|
||||
// key out keeps it readable as the one-cell-per-row file it is.
|
||||
if (row.row >= 0) o << "row = " << row.row << "\nwidth = " << row.width << "\n";
|
||||
@@ -813,6 +850,19 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
||||
o << toml_escape(row.signals[i]);
|
||||
}
|
||||
o << "]\n";
|
||||
};
|
||||
for (const auto& row : state.rows) {
|
||||
o << "\n[[plot]]\n";
|
||||
write_row(row);
|
||||
}
|
||||
// The extra windows: written so a closed one can be reopened, never opened by a launch.
|
||||
for (const auto& window : state.windows) {
|
||||
o << "\n[[window]]\nname = " << toml_escape(window.name)
|
||||
<< "\ndetached = " << (window.detached ? "true" : "false") << "\n";
|
||||
for (const auto& row : window.rows) {
|
||||
o << "\n[[window.plot]]\n";
|
||||
write_row(row);
|
||||
}
|
||||
}
|
||||
for (const auto& cal : state.calibration) {
|
||||
o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain
|
||||
|
||||
@@ -36,6 +36,19 @@ struct LayoutState {
|
||||
double window_s = 0.0; ///< 0 keeps the default window
|
||||
std::vector<Row> rows;
|
||||
|
||||
/// An **extra window**: a scope or detached window, remembered with the session but never opened
|
||||
/// by a launch. A window is a view a user closed, and bringing one back is a decision -- the
|
||||
/// *Reopen closed windows* entry asks for it, so a start never conjures windows onto a desktop
|
||||
/// that does not want them (a tiling WM would tile them straight away). `name` is the window's own
|
||||
/// short name (`plot 2`, `scope 3`), `detached` whether its figure came from a detach, and `rows`
|
||||
/// the same plot rows the main layout carries: signals, row weights and cell widths.
|
||||
struct Window {
|
||||
std::string name;
|
||||
bool detached = false;
|
||||
std::vector<Row> rows;
|
||||
};
|
||||
std::vector<Window> windows;
|
||||
|
||||
struct Calibration {
|
||||
std::string name;
|
||||
double gain = 1.0;
|
||||
|
||||
@@ -945,6 +945,16 @@ void RowSet::place_cursors(Row& r, plot::CursorPlacement placement) {
|
||||
|
||||
}
|
||||
|
||||
void RowSet::set_cursors(double a, double b) {
|
||||
if (a == cursor_a_x_ && b == cursor_b_x_) return;
|
||||
cursor_a_x_ = a;
|
||||
cursor_b_x_ = b;
|
||||
// `Free`: the pair is the owner's already-committed position (a gesture in another window, or
|
||||
// this window's own last sync); snapping it here would fight the hand still moving it.
|
||||
for (int i = 0; i < rows_; ++i) place_cursors(row(i), plot::CursorPlacement::Free);
|
||||
place_crossings();
|
||||
}
|
||||
|
||||
void RowSet::set_cursor_mode(CursorMode mode) {
|
||||
if (mode == cursor_mode_) return;
|
||||
cursor_mode_ = mode;
|
||||
@@ -1509,6 +1519,19 @@ void RowSet::pin_event_window(double pre_s, double post_s) {
|
||||
figure_->set_x_link_range(x_group_, {-pre_s, post_s});
|
||||
}
|
||||
|
||||
void RowSet::pin_display_range(double x0, double x1) {
|
||||
if (!(x1 > x0)) return;
|
||||
window_s_ = std::max(1.0e-3, x1 - x0);
|
||||
pinned_ = true;
|
||||
pinned_pre_ = -x0;
|
||||
pinned_post_ = x1;
|
||||
if (!figure_) return;
|
||||
// The same two statements `pin_event_window()` makes, from the range instead of the spans: the
|
||||
// group stops following the live edge and is placed on the event's own window.
|
||||
figure_->set_x_link_follow(x_group_, plot::XFollow::Off);
|
||||
figure_->set_x_link_range(x_group_, {x0, x1});
|
||||
}
|
||||
|
||||
void RowSet::fit_all() {
|
||||
for (int i = 0; i < rows_; ++i) {
|
||||
Row& r = row(i);
|
||||
|
||||
@@ -355,6 +355,16 @@ class RowSet {
|
||||
void place_crossings();
|
||||
CursorMode cursor_mode() const noexcept { return cursor_mode_; }
|
||||
|
||||
/// Writes both vertical cursors at once: the two-time pair is one instrument with one owner
|
||||
/// (the main window's row set), and a scope window is written from it each frame -- reading it
|
||||
/// back from that window's own plots is what lets a drag *there* move the same pair.
|
||||
void set_cursors(double a, double b);
|
||||
|
||||
/// The trigger marker's position and whether it is up: what a scope window is written from, the
|
||||
/// same way it is written from the cursor pair.
|
||||
double trigger_marker_x() const noexcept { return trigger_marker_x_; }
|
||||
bool trigger_marker_visible() const noexcept { return trigger_marker_on_; }
|
||||
|
||||
/// The two vertical cursors' positions, in the **display** x the axis reads -- not stream
|
||||
/// seconds, which they coincided with only while `display_epoch()` was always 0. A live view
|
||||
/// is newest-relative now, so a cursor left at -1.5 s keeps meaning "1.5 s ago" and the data
|
||||
@@ -399,6 +409,10 @@ class RowSet {
|
||||
/// its label stays empty.
|
||||
struct MeasureMark {
|
||||
int row = 0;
|
||||
/// The signal the mark belongs to, so a *scope window* can find the row that plots it (its
|
||||
/// row numbering is its own): a mark that never carried the name could only be placed in a
|
||||
/// copy by index, which a window's own layout editor invalidates.
|
||||
std::string signal;
|
||||
bool level = false;
|
||||
double value = 0.0;
|
||||
plot::u32 color = 0;
|
||||
@@ -570,6 +584,13 @@ class RowSet {
|
||||
/// 0, so nothing outside the pre+post window is visible.
|
||||
void pin_event_window(double pre_s, double post_s);
|
||||
|
||||
/// The same pin, stated as the **range** a second view mirrors: the caller has the display
|
||||
/// range (`x0 < x1`) and not the spans -- a scope window takes `pinned_range()` from the main
|
||||
/// window and hands it on. `pin_event_window()` takes *spans*, and passing it a range's signed
|
||||
/// `min` turned the pin inside out (a scope in Held mode pinned to `[+pre, +post]`: no trace
|
||||
/// and no x labels). A degenerate or inverted range pins nothing.
|
||||
void pin_display_range(double x0, double x1);
|
||||
|
||||
/// Fits every row's y axis to the data it holds, and puts x back on the whole time window --
|
||||
/// `fit_x()`'s x half exactly, so the two cannot drift apart. Like it, **this does not change
|
||||
/// the time window**: the y axes are fitted to their data, x is merely un-zoomed.
|
||||
|
||||
@@ -50,6 +50,7 @@ void register_builtin_sources() {
|
||||
if (done) return;
|
||||
done = true;
|
||||
register_kind(make_udps_kind());
|
||||
register_kind(make_tcps_kind());
|
||||
register_kind(make_e1608_kind());
|
||||
register_kind(make_flatudp_kind());
|
||||
if (std::getenv("UDPSCOPE_PROBE_SOURCE_KINDS") != nullptr) register_kind(make_probe_kind());
|
||||
|
||||
@@ -21,8 +21,9 @@ const std::vector<SourceKind*>& kinds();
|
||||
/// may drop. A later `dlopen` loader would only call `register_kind()`.
|
||||
void register_builtin_sources();
|
||||
|
||||
/// The built-in kinds, each defined in its own file (`udps_kind.cpp`, `probe_source.cpp`, `e1608/e1608_kind.cpp`, `flatudp/flatudp_kind.cpp`).
|
||||
/// The built-in kinds, each defined in its own file (`udps_kind.cpp`, `tcps_kind.cpp`, `probe_source.cpp`, `e1608/e1608_kind.cpp`, `flatudp/flatudp_kind.cpp`).
|
||||
std::unique_ptr<SourceKind> make_udps_kind();
|
||||
std::unique_ptr<SourceKind> make_tcps_kind();
|
||||
std::unique_ptr<SourceKind> make_probe_kind();
|
||||
std::unique_ptr<SourceKind> make_e1608_kind();
|
||||
std::unique_ptr<SourceKind> make_flatudp_kind();
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "source/registry.hpp"
|
||||
#include "source/udps_source.hpp"
|
||||
|
||||
#include <adwaita.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
using ChangeCb = std::shared_ptr<std::function<void()>>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
void on_edited(GtkEditable*, gpointer user) {
|
||||
const auto& cb = *static_cast<ChangeCb*>(user);
|
||||
if (cb && *cb) (*cb)();
|
||||
}
|
||||
|
||||
void on_value(GObject*, GParamSpec*, gpointer user) {
|
||||
const auto& cb = *static_cast<ChangeCb*>(user);
|
||||
if (cb && *cb) (*cb)();
|
||||
}
|
||||
|
||||
void hook_value(GtkWidget* spin, const ChangeCb& cb) {
|
||||
g_signal_connect_data(spin, "notify::value", G_CALLBACK(on_value), new ChangeCb(cb),
|
||||
+[](gpointer d, GClosure*) { delete static_cast<ChangeCb*>(d); },
|
||||
static_cast<GConnectFlags>(0));
|
||||
}
|
||||
|
||||
void hook(GtkWidget* entry, const ChangeCb& cb) {
|
||||
g_signal_connect_data(entry, "changed", G_CALLBACK(on_edited), new ChangeCb(cb),
|
||||
+[](gpointer d, GClosure*) { delete static_cast<ChangeCb*>(d); },
|
||||
static_cast<GConnectFlags>(0));
|
||||
}
|
||||
|
||||
std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); }
|
||||
|
||||
class TcpsKind final : public SourceKind {
|
||||
public:
|
||||
std::string id() const override { return "tcps"; }
|
||||
std::string display_name() const override { return "TCPStreamer"; }
|
||||
|
||||
std::string identity(const SourceConfig& cfg) const override {
|
||||
std::string s = cfg.get("server", "127.0.0.1") + ":" + std::to_string(cfg.get_int("port", 44500));
|
||||
if (!cfg.get("stream").empty()) s += "/" + cfg.get("stream");
|
||||
return s;
|
||||
}
|
||||
|
||||
std::unique_ptr<DataSource> make(const SourceConfig& cfg) const override {
|
||||
return std::make_unique<UdpsSource>(cfg, UdpsSource::Transport::Tcp);
|
||||
}
|
||||
|
||||
ConfigForm make_config_form() const override {
|
||||
GtkWidget* server = entry_row("_Server");
|
||||
GtkWidget* port = GTK_WIDGET(adw_spin_row_new_with_range(1, 65535, 1));
|
||||
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(port), "_Port");
|
||||
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(port), TRUE);
|
||||
adw_spin_row_set_value(ADW_SPIN_ROW(port), 44500);
|
||||
GtkWidget* stream = entry_row("S_tream name");
|
||||
|
||||
ChangeCb cb = std::make_shared<std::function<void()>>();
|
||||
hook(server, cb);
|
||||
hook_value(port, cb);
|
||||
hook(stream, cb);
|
||||
|
||||
ConfigForm f;
|
||||
f.rows = {server, port, stream};
|
||||
f.description =
|
||||
"Leave the stream name empty for a standalone TCPStreamer; name the stream for a TCPStreamService";
|
||||
f.focus = server;
|
||||
f.write = [=](const SourceConfig& c) {
|
||||
gtk_editable_set_text(GTK_EDITABLE(server), c.get("server", "127.0.0.1").c_str());
|
||||
adw_spin_row_set_value(ADW_SPIN_ROW(port), static_cast<double>(c.get_int("port", 44500)));
|
||||
gtk_editable_set_text(GTK_EDITABLE(stream), c.get("stream").c_str());
|
||||
};
|
||||
f.read = [=]() {
|
||||
SourceConfig c;
|
||||
c.set("server", text_of(server));
|
||||
c.set("port", std::to_string(static_cast<long>(adw_spin_row_get_value(ADW_SPIN_ROW(port)))));
|
||||
if (!text_of(stream).empty()) c.set("stream", text_of(stream));
|
||||
return c;
|
||||
};
|
||||
// A stream name is 1..255 bytes and, being the SUBSCRIBE payload, may not hold a comma
|
||||
// (that would select a multiplexed list, which this kind does not support).
|
||||
f.valid = [=]() {
|
||||
const std::string s = text_of(stream);
|
||||
return !text_of(server).empty() && s.size() <= 255 && s.find(',') == std::string::npos;
|
||||
};
|
||||
f.on_change = [cb](std::function<void()> fn) { *cb = std::move(fn); };
|
||||
return f;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<SourceKind> make_tcps_kind() { return std::make_unique<TcpsKind>(); }
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -40,11 +40,14 @@ SignalInfo to_info(const udps_signal_t& s) {
|
||||
|
||||
} // namespace
|
||||
|
||||
UdpsSource::UdpsSource(const SourceConfig& cfg)
|
||||
: server_(cfg.get("server", "127.0.0.1")),
|
||||
port_(static_cast<std::uint16_t>(cfg.get_int("port", 8000))),
|
||||
multicast_(cfg.get("multicast")),
|
||||
interface_(cfg.get("interface")),
|
||||
UdpsSource::UdpsSource(const SourceConfig& cfg, Transport transport)
|
||||
: transport_(transport),
|
||||
server_(cfg.get("server", "127.0.0.1")),
|
||||
port_(static_cast<std::uint16_t>(
|
||||
cfg.get_int("port", transport == Transport::Tcp ? 44500 : 8000))),
|
||||
multicast_(transport == Transport::Tcp ? std::string() : cfg.get("multicast")),
|
||||
interface_(transport == Transport::Tcp ? std::string() : cfg.get("interface")),
|
||||
stream_(transport == Transport::Tcp ? cfg.get("stream") : std::string()),
|
||||
recv_buffer_(static_cast<int>(cfg.get_int("recv_buffer", 8 * 1024 * 1024))) {}
|
||||
|
||||
UdpsSource::~UdpsSource() { close(); }
|
||||
@@ -62,6 +65,10 @@ void UdpsSource::open(SourceSink& sink) {
|
||||
c.silence_timeout_s = 2.0;
|
||||
c.reconnect_delay_s = 0.5;
|
||||
c.keepalive_interval_s = 1.0;
|
||||
if (transport_ == Transport::Tcp) {
|
||||
c.use_tcp = 1;
|
||||
c.stream_name = stream_.empty() ? nullptr : stream_.c_str();
|
||||
}
|
||||
|
||||
client_ = udps_client_create(&c);
|
||||
if (client_ == nullptr) {
|
||||
|
||||
@@ -18,7 +18,9 @@ namespace udpscope {
|
||||
/// all protocol work stays in `deps/udpsclient`.
|
||||
class UdpsSource final : public DataSource {
|
||||
public:
|
||||
explicit UdpsSource(const SourceConfig& cfg);
|
||||
enum class Transport { Udp, Tcp };
|
||||
|
||||
explicit UdpsSource(const SourceConfig& cfg, Transport transport = Transport::Udp);
|
||||
~UdpsSource() override;
|
||||
|
||||
void open(SourceSink& sink) override;
|
||||
@@ -27,6 +29,7 @@ class UdpsSource final : public DataSource {
|
||||
SourceStats stats() const override;
|
||||
bool connected() const override;
|
||||
void set_datagram_tap(DatagramTap tap) override { tap_ = std::move(tap); }
|
||||
Transport transport() const { return transport_; }
|
||||
|
||||
private:
|
||||
static void config_cb(const udps_signal_t* signals, std::uint32_t count,
|
||||
@@ -36,10 +39,12 @@ class UdpsSource final : public DataSource {
|
||||
static void datagram_cb(const std::uint8_t* bytes, std::size_t len, double recv_time,
|
||||
void* user);
|
||||
|
||||
Transport transport_ = Transport::Udp;
|
||||
std::string server_;
|
||||
std::uint16_t port_ = 8000;
|
||||
std::string multicast_;
|
||||
std::string interface_;
|
||||
std::string stream_;
|
||||
int recv_buffer_ = 8 * 1024 * 1024;
|
||||
|
||||
udps_client_t* client_ = nullptr;
|
||||
|
||||
+904
-12
File diff suppressed because it is too large.
Load diff
+102
-1
@@ -215,6 +215,40 @@ struct App {
|
||||
GtkWindow* window = nullptr;
|
||||
plot::gtk4::PlotArea* area = nullptr;
|
||||
GtkWidget* widget = nullptr;
|
||||
/// The window's short name, the one the signal page's *Plot on* groups carry ("plot 2" for a
|
||||
/// copy, "scope 3" for an empty one): a button must say which window it plots into.
|
||||
std::string name;
|
||||
/// The drop target wired onto `widget`: the handler reads the *window* off it (each window's
|
||||
/// target names its own scope), and the probe calls the handler with it directly.
|
||||
GtkDropTarget* drop = nullptr;
|
||||
/// This window came from a **detach** -- the figure left the main window to live here -- so it
|
||||
/// carries the attach control and can be put back. A copied or empty window is a plain second
|
||||
/// view, and has no attach control.
|
||||
bool detached = false;
|
||||
/// False while attaching: the window is closing *because* its figure went back to the main
|
||||
/// window, so there is nothing to remember (remembering it would offer to reopen a window
|
||||
/// whose figure is on screen again).
|
||||
bool remember_on_close = true;
|
||||
/// The overlay wrapping the GL widget: the corner controls (attach, the layout toggle, the
|
||||
/// layout editor's own buttons) all live on it. Its motion controller keeps the pointer state
|
||||
/// they are revealed by.
|
||||
GtkWidget* overlay = nullptr;
|
||||
/// Layout editing in this window: its own editor and toggle. A scope is a view with its own
|
||||
/// arrangement, so it holds as many figure rows as it is given and edits them with the same
|
||||
/// `+` / `×` / Done controls the main window's editor has -- its own, never the main window's.
|
||||
bool layout_editing = false;
|
||||
GtkWidget* layout_toggle = nullptr;
|
||||
std::vector<GtkWidget*> layout_buttons;
|
||||
GtkWidget* layout_done = nullptr;
|
||||
int layout_buttons_rows = -1;
|
||||
std::vector<GtkWidget*> attach_buttons;
|
||||
double pointer_x = 0.0;
|
||||
double pointer_y = 0.0;
|
||||
bool pointer_valid = false;
|
||||
/// Frames this window's area was watched for its first paint: a window whose GL context failed
|
||||
/// or that never paints says so in its own title (and on stderr) instead of staying a blank
|
||||
/// rectangle nobody can diagnose.
|
||||
int paint_checks = 0;
|
||||
/// The area's initial plot, owned here: `PlotArea::create()` wants a `Plot&` that
|
||||
/// outlives the widget, and sharing the main window's placeholder would hand two areas
|
||||
/// one plot the moment the figure mode is ever left.
|
||||
@@ -222,6 +256,14 @@ struct App {
|
||||
std::unique_ptr<RowSet> rows;
|
||||
};
|
||||
std::vector<std::unique_ptr<ScopeWindow>> scopes;
|
||||
/// The **closed** extra windows, as the session remembers them: a window's arrangement is kept
|
||||
/// when it is closed (and across a restart), and the *Reopen closed windows* entry opens them --
|
||||
/// a launch never does, because a window is a view the user closed on purpose.
|
||||
std::vector<LayoutState::Window> closed_windows;
|
||||
/// The menu entry that reopens them, so its label can carry the count.
|
||||
GtkWidget* reopen_scopes_item = nullptr;
|
||||
/// Every empty scope window is numbered from this, so two of them never share a name.
|
||||
int scope_seq = 0;
|
||||
|
||||
/// On while the layout editor is up. Nothing else about the plot changes: the editor is
|
||||
/// a set of overlay buttons, not a mode the figure knows about.
|
||||
@@ -235,6 +277,10 @@ struct App {
|
||||
/// count changes (`maximize_buttons_rows` is the count they were built for).
|
||||
std::vector<GtkWidget*> maximize_buttons;
|
||||
int maximize_buttons_rows = -1;
|
||||
/// The **detach** buttons: one per figure row beside its full-screen toggle, same reveal,
|
||||
/// same rebuild cadence -- but only while the layout holds two or more figures (detaching the
|
||||
/// only figure would leave the main window with nothing to draw) and never while maximized.
|
||||
std::vector<GtkWidget*> detach_buttons;
|
||||
/// The boundary a resize drag is on, and the weights it started from: a drag is *relative* to where it
|
||||
/// began, so the beginning has to be kept -- reading the weights while dragging would compound.
|
||||
int layout_drag_row = -1;
|
||||
@@ -870,6 +916,7 @@ struct App {
|
||||
int dbg_maximize_step = 0;
|
||||
std::size_t dbg_maximize_single = 0;
|
||||
int dbg_maximize_hidden = -1;
|
||||
std::size_t dbg_detach_single = 0;
|
||||
/// Which step of the scope-window probe runs next (`UDPSCOPE_PROBE_SCOPE_MS`).
|
||||
int dbg_scope_step = 0;
|
||||
int cursors_pane_mode = -1;
|
||||
@@ -1627,6 +1674,9 @@ struct ExportContext {
|
||||
};
|
||||
struct FixedRangeContext {
|
||||
App* app;
|
||||
/// The row set the range applies to: `nullptr` is the main window's. A scope window's own
|
||||
/// dialog writes its window's rows.
|
||||
RowSet* rows = nullptr;
|
||||
int row;
|
||||
GtkWidget* lo;
|
||||
GtkWidget* hi;
|
||||
@@ -2038,6 +2088,41 @@ void row_menu_fill(RowMenuState& st);
|
||||
GtkWidget* open_row_menu(App& app, int row, double x, double y, GtkWidget* parent, bool autohide = true);
|
||||
void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user);
|
||||
gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user);
|
||||
/// The same drop, onto a **scope window's** plot: the target carries the window, the row is the
|
||||
/// window's own cell under the pointer, and the assignment lands in that window's row set.
|
||||
gboolean on_scope_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user);
|
||||
/// Plots `signal` on `row` of a scope window's row set (the signal page's *Plot on* buttons and the
|
||||
/// drop target both end here). False, with a notice, when the signal cannot be acquired or the plot
|
||||
/// holds four signals already.
|
||||
bool assign_signal_to_scope(App& app, App::ScopeWindow& scope, std::size_t signal, int row);
|
||||
/// Runs one keyboard accelerator. Scope windows carry the same shortcuts as the main window: the
|
||||
/// view-level ones (`L` -- the layout editor) act on the window the key arrived in; the rest reach
|
||||
/// the application through `run_app_action()`.
|
||||
gboolean on_scope_key(GtkEventControllerKey* controller, guint keyval, guint keycode,
|
||||
GdkModifierType state, gpointer user);
|
||||
/// The session record of a window: its name, whether it was detached, and its plot rows.
|
||||
LayoutState::Window scope_window_state(const App::ScopeWindow& scope);
|
||||
/// Remembers a window as closed (`closed_windows`, replaced by name) -- what its destroy handler
|
||||
/// does, and what keeps a closed window reopenable.
|
||||
void remember_closed_window(App& app, const App::ScopeWindow& scope);
|
||||
/// Opens a window from a remembered state: an empty scope window, then its rows, weights and
|
||||
/// signals. Returns the opened window (nullptr when it could not be made).
|
||||
App::ScopeWindow* open_scope_window_from_state(App& app, const LayoutState::Window& state);
|
||||
/// Writes the main window's instruments -- the two cursors, the gate band, the trigger marker --
|
||||
/// into every scope window, and adopts one dragged *there* back into the main window first. Called
|
||||
/// once per frame from the main tick, after the main's own `sync_cursors()`/`sync_gate()`.
|
||||
void sync_scope_instruments(App& app);
|
||||
/// The main window's measurement marks, translated to each scope window's own rows (by signal
|
||||
/// name) and placed there: a window that plots a measured signal draws its mark.
|
||||
void mirror_measure_marks(App& app);
|
||||
/// Refreshes the *Reopen closed windows* entry: its count and whether it is sensitive.
|
||||
void sync_reopen_scopes_item(App& app);
|
||||
/// Turns a scope window's own layout editor on or off, building or taking down its buttons.
|
||||
void set_scope_layout_editing(App& app, App::ScopeWindow& scope, bool on);
|
||||
/// The scope window's own right-click menu: the same row menu's actions, aimed at **that
|
||||
/// window's** row set.
|
||||
void on_scope_right_click(GtkGestureClick* gesture, int n_press, double x, double y,
|
||||
gpointer user);
|
||||
/// Reads each pane's own switch back off the header bar's button, and the other way round: the two are
|
||||
/// one control in two places, and `panel_switch_sync` keeps the write from looking like a click.
|
||||
void sync_panel_switches(App& app);
|
||||
@@ -2146,7 +2231,16 @@ gboolean scope_probe_step(gpointer user);
|
||||
/// A second top-level window showing the same stream through its own figure: `figure_row` >= 0
|
||||
/// copies that figure of the main window (cells, widths, every slot's signal), -1 opens an
|
||||
/// empty one-row scope. The main window's tick binds, acquires and repaints it.
|
||||
void open_scope_window(App& app, int figure_row);
|
||||
void open_scope_window(App& app, int figure_row, bool detached = false);
|
||||
/// Detaches figure row `fr` into a window of its own: the figure is copied into a new scope
|
||||
/// window -- which is what makes the window *detached*, the one kind with an attach control --
|
||||
/// and removed from the main window's rows. Refused, changing nothing, for a one-figure layout
|
||||
/// or a maximized one: detaching then would leave the main window with nothing to draw.
|
||||
void detach_figure(App& app, int fr);
|
||||
/// Puts a detached window's figure back: a figure row is appended to the main window carrying
|
||||
/// the window's arrangement (cells, weights, every slot's signal in its colour) and the window
|
||||
/// closes. The window must be a detached one; anything else returns without changing.
|
||||
void attach_scope_window(App& app, App::ScopeWindow& scope);
|
||||
gboolean measurepin_probe(gpointer user);
|
||||
gboolean measurepin_held_wait(gpointer user);
|
||||
gboolean measurepin_pause_wait(gpointer user);
|
||||
@@ -2214,6 +2308,13 @@ gboolean flatudp_probe(gpointer user);
|
||||
gboolean flatudp_probe_report(gpointer user);
|
||||
gboolean flatudp_probe_gap(gpointer user);
|
||||
gboolean flatudp_probe_resumed(gpointer user);
|
||||
/// The TCPStreamer kind: `tcps_probe` enables every signal, `tcps_probe_report` reads the stream
|
||||
/// and the capture button a second later; `tcps_probe_reconnect` records a baseline and
|
||||
/// `tcps_probe_reconnect_report` checks a reconnect moved the counters on.
|
||||
gboolean tcps_probe(gpointer user);
|
||||
gboolean tcps_probe_report(gpointer user);
|
||||
gboolean tcps_probe_reconnect(gpointer user);
|
||||
gboolean tcps_probe_reconnect_report(gpointer user);
|
||||
gboolean pan_probe(gpointer user);
|
||||
gboolean arm_probe(gpointer user);
|
||||
gboolean trigrenum_probe_step3(gpointer user);
|
||||
|
||||
@@ -14,6 +14,46 @@ namespace ui_impl {
|
||||
|
||||
/// Reads the session layout back out of the model and rows: the display window,
|
||||
/// each row's slot signal names, and each signal's calibration.
|
||||
LayoutState::Window scope_window_state(const App::ScopeWindow& scope) {
|
||||
LayoutState::Window w;
|
||||
w.name = scope.name;
|
||||
w.detached = scope.detached;
|
||||
if (scope.rows == nullptr) return w;
|
||||
for (int i = 0; i < scope.rows->row_count(); ++i) {
|
||||
LayoutState::Row row;
|
||||
const RowSet::Row& r = scope.rows->row(i);
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||
const std::size_t si = static_cast<std::size_t>(s);
|
||||
row.signals.push_back(r.layer[si] > 0 ? std::string() : r.signal_name[si]);
|
||||
}
|
||||
const int fr = scope.rows->figure_row_of(i);
|
||||
const std::vector<float>& weights = scope.rows->row_weights();
|
||||
row.row = fr;
|
||||
if (fr >= 0 && static_cast<std::size_t>(fr) < weights.size()) {
|
||||
row.weight = weights[static_cast<std::size_t>(fr)];
|
||||
}
|
||||
const std::vector<float>& cw = scope.rows->cell_weights(fr);
|
||||
const int cell = scope.rows->cell_of(i);
|
||||
if (cell >= 0 && static_cast<std::size_t>(cell) < cw.size()) row.width = cw[static_cast<std::size_t>(cell)];
|
||||
w.rows.push_back(std::move(row));
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
void remember_closed_window(App& app, const App::ScopeWindow& scope) {
|
||||
LayoutState::Window w = scope_window_state(scope);
|
||||
for (LayoutState::Window& known : app.closed_windows) {
|
||||
if (known.name != w.name) continue;
|
||||
known = std::move(w);
|
||||
sync_reopen_scopes_item(app);
|
||||
schedule_layout_save(app);
|
||||
return;
|
||||
}
|
||||
app.closed_windows.push_back(std::move(w));
|
||||
sync_reopen_scopes_item(app);
|
||||
schedule_layout_save(app);
|
||||
}
|
||||
|
||||
LayoutState collect_layout(App& app) {
|
||||
LayoutState st;
|
||||
st.window_s = app.rows->time_window();
|
||||
@@ -64,6 +104,12 @@ LayoutState collect_layout(App& app) {
|
||||
if (model.signals()[si].math || !on[si]) continue;
|
||||
st.enabled.push_back(model.signals()[si].name);
|
||||
}
|
||||
// The extra windows: the **open** ones by their present arrangement, then the closed ones as
|
||||
// they were remembered. A launch never opens them; the *Reopen closed windows* entry does.
|
||||
for (const std::unique_ptr<App::ScopeWindow>& scope : app.scopes) {
|
||||
st.windows.push_back(scope_window_state(*scope));
|
||||
}
|
||||
for (const LayoutState::Window& w : app.closed_windows) st.windows.push_back(w);
|
||||
// Only the signals the user actually re-coloured: the rest take the positional palette, and
|
||||
// writing those out would freeze a colour that is meant to follow the signal list.
|
||||
for (const auto& [name, rgb] : app.rows->signal_colors()) {
|
||||
@@ -437,6 +483,10 @@ void apply_layout(App& app, const LayoutState& st) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// The extra windows are **remembered, not opened**: a session's windows are views the user
|
||||
// closed, and a launch that tiled them onto the desktop would take that decision for them.
|
||||
app.closed_windows = st.windows;
|
||||
sync_reopen_scopes_item(app);
|
||||
app.measures = st.measures;
|
||||
app.measure_stats_on = st.measure_stats;
|
||||
app.updating_panels = true;
|
||||
|
||||
@@ -296,6 +296,7 @@ void place_measure_marks(App& app) {
|
||||
}
|
||||
}
|
||||
app.rows->set_measure_marks(app.measure_marks_display);
|
||||
mirror_measure_marks(app);
|
||||
}
|
||||
|
||||
/// Recomputes every measurement over the window it describes and writes the results into the
|
||||
@@ -734,6 +735,7 @@ void update_measures(App& app, bool force, bool accumulate) {
|
||||
// `MeasureMarkSpec` -- the badge text is built where the display conversion happens).
|
||||
RowSet::MeasureMark mark;
|
||||
mark.row = row;
|
||||
mark.signal = m.signal;
|
||||
mark.level = spec.level;
|
||||
mark.value = spec.level ? spec.value : spec.value + s0;
|
||||
mark.color = color;
|
||||
|
||||
@@ -287,6 +287,9 @@ void open_preferences(App& app) {
|
||||
// The crosshair's axis badges, at once like the theme: it is a drawing flag on the
|
||||
// plots, and a change is the next frame's drawing.
|
||||
app.rows->set_crosshair_axis_labels(s.crosshair_axis_labels);
|
||||
for (const std::unique_ptr<App::ScopeWindow>& scope : app.scopes) {
|
||||
if (scope->rows != nullptr) scope->rows->set_crosshair_axis_labels(s.crosshair_axis_labels);
|
||||
}
|
||||
// Only a failure is announced. A change arrives once per keystroke in the directory row, and a
|
||||
// status line that flashed on each one would be the app shouting about its own bookkeeping.
|
||||
if (!written) set_notice(app, "could not write the preferences file", 4);
|
||||
|
||||
+402
-17
@@ -11,6 +11,7 @@
|
||||
#include "source/flatudp/flatudp_signals.hpp"
|
||||
#include "source/flatudp/flatudp_source.hpp"
|
||||
#include "source/registry.hpp"
|
||||
#include "source/udps_source.hpp"
|
||||
#include "ui_app.hpp"
|
||||
|
||||
#include <sys/stat.h>
|
||||
@@ -1651,6 +1652,7 @@ gboolean maximize_probe_step(gpointer user) {
|
||||
// A layout of one figure carries no full-screen toggle (there is nothing to enlarge
|
||||
// *to*); the button appears together with the second figure row, below.
|
||||
a->dbg_maximize_single = a->maximize_buttons.size();
|
||||
a->dbg_detach_single = a->detach_buttons.size();
|
||||
a->rows->set_cells_per_row({1, 1});
|
||||
a->rows->assign(0, 0, sine, sine, sine_color);
|
||||
a->rows->assign(1, 0, square, square, square_color);
|
||||
@@ -1684,11 +1686,13 @@ gboolean maximize_probe_step(gpointer user) {
|
||||
const std::string row1_back = a->rows->row(1).signal_name[0];
|
||||
const bool flag_back = a->rows->figures_maximized();
|
||||
std::fprintf(stderr,
|
||||
"[maximize-probe] single_buttons=%zu before: rows=%d frows=%d row0=%s row1=%s "
|
||||
"buttons=%zu; max: rows=%d frows=%d flag=%d row0=%s; "
|
||||
"[maximize-probe] single_buttons=%zu detach_single=%zu before: rows=%d frows=%d "
|
||||
"row0=%s row1=%s buttons=%zu detach=%zu; max: rows=%d frows=%d flag=%d row0=%s; "
|
||||
"back: rows=%d row0=%s row1=%s flag=%d\n",
|
||||
a->dbg_maximize_single, rows_before, frows_before, row0_before.c_str(),
|
||||
row1_before.c_str(), buttons, rows_max, frows_max, flag_max ? 1 : 0,
|
||||
a->dbg_maximize_single, a->dbg_detach_single, rows_before, frows_before,
|
||||
row0_before.c_str(),
|
||||
row1_before.c_str(), buttons, a->detach_buttons.size(), rows_max, frows_max,
|
||||
flag_max ? 1 : 0,
|
||||
row0_max.c_str(), rows_back, row0_back.c_str(), row1_back.c_str(),
|
||||
flag_back ? 1 : 0);
|
||||
std::fflush(stderr);
|
||||
@@ -1703,25 +1707,37 @@ gboolean maximize_probe_step(gpointer user) {
|
||||
case 2: {
|
||||
a->dbg_maximize_hidden =
|
||||
a->maximize_buttons.empty() || !gtk_widget_get_visible(a->maximize_buttons[0]) ? 1 : 0;
|
||||
// On the **detach** button's own centre -- its zone is the strictest of the corner pair, so a
|
||||
// pointer there must reveal both it and the full-screen toggle beside it.
|
||||
if (const plot::Plot* last = a->rows->row(0).plot) {
|
||||
const double scale =
|
||||
a->area != nullptr && a->area->device_scale() > 0.0f
|
||||
? static_cast<double>(a->area->device_scale())
|
||||
: 1.0;
|
||||
const double right = static_cast<double>(last->layout().axes.right()) / scale;
|
||||
a->area_pointer_x = right - 2 * kLayoutButtonPx - 2 * kLayoutButtonPad +
|
||||
0.5 * kLayoutButtonPx;
|
||||
a->area_pointer_y = kLayoutButtonPad + 0.5 * kLayoutButtonPx;
|
||||
} else {
|
||||
a->area_pointer_x = static_cast<double>(gtk_widget_get_width(a->area_widget)) - 8.0;
|
||||
a->area_pointer_y = 8.0;
|
||||
}
|
||||
a->area_pointer_valid = true;
|
||||
a->dbg_maximize_step = 3;
|
||||
g_timeout_add(250, maximize_probe_step, a);
|
||||
break;
|
||||
}
|
||||
case 3: {
|
||||
const int shown = !a->maximize_buttons.empty() &&
|
||||
gtk_widget_get_visible(a->maximize_buttons[0])
|
||||
? 1
|
||||
: 0;
|
||||
const int other = a->maximize_buttons.size() > 1 &&
|
||||
gtk_widget_get_visible(a->maximize_buttons[1])
|
||||
? 1
|
||||
: 0;
|
||||
const auto visible = [](const std::vector<GtkWidget*>& buttons, std::size_t i) {
|
||||
return i < buttons.size() && gtk_widget_get_visible(buttons[i]) ? 1 : 0;
|
||||
};
|
||||
const int shown = visible(a->maximize_buttons, 0);
|
||||
const int other = visible(a->maximize_buttons, 1);
|
||||
const int dshown = visible(a->detach_buttons, 0);
|
||||
const int dother = visible(a->detach_buttons, 1);
|
||||
std::fprintf(stderr, "[maximize-probe] corner: hidden_when_off=%d shown_when_near=%d "
|
||||
"other_row=%d\n",
|
||||
a->dbg_maximize_hidden, shown, other);
|
||||
"other_row=%d detach_near=%d detach_other=%d\n",
|
||||
a->dbg_maximize_hidden, shown, other, dshown, dother);
|
||||
std::fflush(stderr);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
@@ -1736,11 +1752,29 @@ gboolean maximize_probe_step(gpointer user) {
|
||||
/// bound to the same stream a few frames later, while the empty one stays empty. The window
|
||||
/// creation itself (a real `GtkWindow`, an own `PlotArea`, its own `RowSet`) is exercised by
|
||||
/// the same call the two menu entries make.
|
||||
/// How many descendants carry `name` -- the scope window's *Plot on* groups are counted, one per
|
||||
/// window, where a single `find_named` would stop at the first.
|
||||
int count_named(GtkWidget* root, const char* name) {
|
||||
int n = 0;
|
||||
if (root == nullptr) return 0;
|
||||
if (strcmp(gtk_widget_get_name(root), name) == 0) ++n;
|
||||
for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr;
|
||||
c = gtk_widget_get_next_sibling(c)) {
|
||||
n += count_named(c, name);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
gboolean scope_probe_step(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||
switch (a->dbg_scope_step) {
|
||||
case 0: {
|
||||
// What the session remembered, and what a launch opened: the windows are never opened at
|
||||
// start, so `scopes` is one (the main window) and `closed` is the count read from the file.
|
||||
std::fprintf(stderr, "[scope-probe] loaded closed=%zu scopes=%zu\n",
|
||||
a->closed_windows.size(), a->scopes.size());
|
||||
std::fflush(stderr);
|
||||
const std::vector<SignalInfo>& signals = a->model->signals();
|
||||
for (std::size_t i = 0; i < signals.size(); ++i) {
|
||||
if (signals[i].name == "sine") a->model->set_signal_enabled(i, true);
|
||||
@@ -1777,15 +1811,284 @@ gboolean scope_probe_step(gpointer user) {
|
||||
}
|
||||
const App::ScopeWindow& copy = *a->scopes[0];
|
||||
const App::ScopeWindow& empty = *a->scopes[1];
|
||||
const std::string copied = copy.rows->row(0).signal_name[0];
|
||||
const bool copied_bound = copy.rows->row(0).channel[0] >= 0;
|
||||
const bool empty_ok = empty.rows->row(0).signal_name[0].empty();
|
||||
// The first non-empty slot of the copied row: the copy is slot-for-slot faithful, and slot 0
|
||||
// is empty whenever an earlier probe (the enable-all one disables a signal at its end) left
|
||||
// the main's slot 0 empty -- so this reads *a* copied trace without assuming which slot.
|
||||
std::string copied;
|
||||
bool copied_bound = false;
|
||||
for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) {
|
||||
const std::size_t si = static_cast<std::size_t>(sl);
|
||||
if (copy.rows->row(0).signal_name[si].empty()) continue;
|
||||
copied = copy.rows->row(0).signal_name[si];
|
||||
copied_bound = copy.rows->row(0).channel[si] >= 0;
|
||||
break;
|
||||
}
|
||||
bool empty_ok = true;
|
||||
for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) {
|
||||
if (!empty.rows->row(0).signal_name[static_cast<std::size_t>(sl)].empty()) empty_ok = false;
|
||||
}
|
||||
const bool windows = copy.window != nullptr && empty.window != nullptr &&
|
||||
gtk_widget_get_visible(GTK_WIDGET(copy.window)) &&
|
||||
gtk_widget_get_visible(GTK_WIDGET(empty.window));
|
||||
std::fprintf(stderr,
|
||||
"[scope-probe] count=2 copied='%s' bound=%d empty_ok=%d windows=%d\n",
|
||||
copied.c_str(), copied_bound ? 1 : 0, empty_ok ? 1 : 0, windows ? 1 : 0);
|
||||
// **A drop onto a window**: the handler the drop target wires, called directly with a
|
||||
// cell-0 coordinate. The copied window holds 'sine' in slot 0, so the dropped signal must
|
||||
// land in its second slot (the rebuild and the area's re-point are the window's next frame).
|
||||
std::size_t square = std::size_t(-1);
|
||||
for (std::size_t i = 0; i < a->model->signals().size(); ++i) {
|
||||
if (a->model->signals()[i].name == "square") square = i;
|
||||
}
|
||||
GValue payload = G_VALUE_INIT;
|
||||
g_value_init(&payload, G_TYPE_STRING);
|
||||
g_value_set_string(&payload, std::to_string(square).c_str());
|
||||
const gboolean dropped = square != std::size_t(-1) && copy.drop != nullptr
|
||||
? on_scope_drop_channel(copy.drop, &payload, 40.0, 20.0, a)
|
||||
: FALSE;
|
||||
g_value_unset(&payload);
|
||||
const std::string slot1 = copy.rows->row(0).signal_name[1];
|
||||
// The same drop onto the **empty** window: its figure must gain the trace and, after the
|
||||
// window's next frame, draw it (the screenshots a run of this probe takes read it back).
|
||||
GValue payload2 = G_VALUE_INIT;
|
||||
g_value_init(&payload2, G_TYPE_STRING);
|
||||
g_value_set_string(&payload2, std::to_string(square).c_str());
|
||||
const gboolean dropped2 = square != std::size_t(-1) && empty.drop != nullptr
|
||||
? on_scope_drop_channel(empty.drop, &payload2, 40.0, 20.0, a)
|
||||
: FALSE;
|
||||
g_value_unset(&payload2);
|
||||
const std::string empty_slot0 = empty.rows->row(0).signal_name[0];
|
||||
// **The signal page lists the windows**: one *Plot on* group per window, in the page of a
|
||||
// signal that is plotted nowhere (so every button is live).
|
||||
signal_page_ensure(*a, "ramp");
|
||||
signal_pages_sync(*a);
|
||||
int groups = 0;
|
||||
const auto page_it = a->signal_pages.find("ramp");
|
||||
if (page_it != a->signal_pages.end()) groups = count_named(page_it->second, "udpscope-scope-plot-on");
|
||||
std::fprintf(stderr,
|
||||
"[scope-probe] drop=%d slot1='%s' page_groups=%d empty_drop=%d "
|
||||
"empty_slot0='%s'\n",
|
||||
dropped ? 1 : 0, slot1.c_str(), groups, dropped2 ? 1 : 0,
|
||||
empty_slot0.c_str());
|
||||
// **Detach and attach**: a second figure row detaches -- the window is marked detached and
|
||||
// carries the attach control -- and its figure comes back on the main window.
|
||||
a->rows->set_cells_per_row({1, 1});
|
||||
{
|
||||
std::size_t ramp = std::size_t(-1);
|
||||
int color = 0;
|
||||
for (std::size_t i = 0; i < a->model->signals().size(); ++i) {
|
||||
if (a->model->signals()[i].name == "ramp") {
|
||||
ramp = i;
|
||||
color = signal_color_index(a->model->signals(), i);
|
||||
}
|
||||
}
|
||||
if (ramp != std::size_t(-1)) a->rows->assign(1, 0, "ramp", "ramp", color);
|
||||
}
|
||||
a->relayout_pending = true;
|
||||
detach_figure(*a, 1);
|
||||
const bool detached_ok = a->scopes.size() == 3 && a->scopes[2]->detached &&
|
||||
a->rows->figure_row_count() == 1 &&
|
||||
a->scopes[2]->rows->row(0).signal_name[0] == "ramp" &&
|
||||
a->scopes[2]->attach_buttons.size() == 1;
|
||||
attach_scope_window(*a, *a->scopes[2]);
|
||||
const int frows_back = a->rows->figure_row_count();
|
||||
const bool attach_ok = a->scopes.size() == 2 && frows_back == 2 &&
|
||||
a->rows->row(a->rows->first_plot_of_row(1)).signal_name[0] == "ramp";
|
||||
std::fprintf(stderr,
|
||||
"[scope-probe] detach=%d scopes=3 frows=1 attach=%d scopes_back=2 "
|
||||
"frows_back=%d slot='ramp'\n",
|
||||
detached_ok ? 1 : 0, attach_ok ? 1 : 0, frows_back);
|
||||
std::fflush(stderr);
|
||||
// And a detached window **left open**, so the screenshots a run of this probe takes can
|
||||
// answer "does the detached figure draw": figure 0 (sine) moves out and stays out.
|
||||
detach_figure(*a, 0);
|
||||
a->dbg_scope_step = 3;
|
||||
g_timeout_add(800, scope_probe_step, a);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
case 3: {
|
||||
// The windows' **own GL account**: a scope whose context failed -- which a second GL area
|
||||
// on a real driver can hit where the software renderer does not -- draws *nothing*: no
|
||||
// axes, no traces, exactly the "blank plot widget" report. `frames=` counts staged frames;
|
||||
// `ok=0` carries the adapter's message.
|
||||
const auto gl_state = [](const App::ScopeWindow& s) {
|
||||
if (s.area == nullptr) return std::string("no-area");
|
||||
const plot::Status st = s.area->init_status();
|
||||
return st ? "ok(" + std::to_string(s.area->frames_painted()) + "f)"
|
||||
: std::string("err: ") + st.message;
|
||||
};
|
||||
const bool empty_bound = a->scopes[1]->rows->row(0).channel[0] >= 0;
|
||||
std::fprintf(stderr, "[scope-probe] gl copy=%s empty=%s empty_bound=%d\n",
|
||||
gl_state(*a->scopes[0]).c_str(), gl_state(*a->scopes[1]).c_str(),
|
||||
empty_bound ? 1 : 0);
|
||||
if (!a->scopes.empty()) {
|
||||
const App::ScopeWindow& det = *a->scopes.back();
|
||||
const plot::Status st = det.area != nullptr ? det.area->init_status() : plot::Status{};
|
||||
std::fprintf(stderr,
|
||||
"[scope-probe] gl detached ok=%d frames=%llu rows=%d slot0='%s'\n",
|
||||
(bool)st ? 1 : 0,
|
||||
static_cast<unsigned long long>(det.area != nullptr
|
||||
? det.area->frames_painted()
|
||||
: 0),
|
||||
det.rows != nullptr ? det.rows->row_count() : 0,
|
||||
det.rows != nullptr && det.rows->row_count() > 0
|
||||
? det.rows->row(0).signal_name[0].c_str()
|
||||
: "");
|
||||
}
|
||||
// The scope window carries its own right-click menu (its gesture is on the GL area, wired
|
||||
// beside the drop target): one click gesture whose button is 3, per window.
|
||||
int right_clicks = 0;
|
||||
GtkWidget* det_widget = a->scopes.empty() ? nullptr : a->scopes.back()->widget;
|
||||
if (det_widget != nullptr) {
|
||||
GListModel* controllers = gtk_widget_observe_controllers(det_widget);
|
||||
const guint n = g_list_model_get_n_items(G_LIST_MODEL(controllers));
|
||||
for (guint i = 0; i < n; ++i) {
|
||||
GObject* c = G_OBJECT(g_list_model_get_item(controllers, i));
|
||||
if (GTK_IS_GESTURE_SINGLE(c) &&
|
||||
gtk_gesture_single_get_button(GTK_GESTURE_SINGLE(c)) == 3) {
|
||||
++right_clicks;
|
||||
}
|
||||
g_object_unref(c);
|
||||
}
|
||||
g_object_unref(controllers);
|
||||
}
|
||||
std::fprintf(stderr, "[scope-probe] rclick=%d\n", right_clicks);
|
||||
std::fflush(stderr);
|
||||
// **The pin mirror**: a held event pins the main window, and every scope must be pinned to
|
||||
// the *same range*. `pin_event_window()` takes spans (pre, post) and passing it a range's
|
||||
// signed min turned the pin inside out (a scope in Held drew no trace and no x labels).
|
||||
a->rows->pin_event_window(1.6, 0.4);
|
||||
a->dbg_scope_step = 4;
|
||||
g_timeout_add(350, scope_probe_step, a);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
case 4: {
|
||||
const plot::Range main_pin = a->rows->pinned_range();
|
||||
const plot::Range scope_pin = a->scopes[0]->rows->pinned_range();
|
||||
const plot::Range scope_x = a->scopes[0]->rows->row(0).plot != nullptr
|
||||
? a->scopes[0]->rows->row(0).plot->x_range(0)
|
||||
: plot::Range{};
|
||||
const bool pinned = a->scopes[0]->rows->window_pinned() == a->rows->window_pinned();
|
||||
const bool same = std::fabs(scope_pin.min - main_pin.min) < 1e-9 &&
|
||||
std::fabs(scope_pin.max - main_pin.max) < 1e-9 &&
|
||||
scope_x.max > scope_x.min;
|
||||
std::fprintf(stderr,
|
||||
"[scope-probe] pin_mirror=%d main=[%.3f..%.3f] scope=[%.3f..%.3f] "
|
||||
"x=[%.3f..%.3f]\n",
|
||||
pinned && same ? 1 : 0, main_pin.min, main_pin.max, scope_pin.min,
|
||||
scope_pin.max, scope_x.min, scope_x.max);
|
||||
std::fflush(stderr);
|
||||
a->rows->set_time_window(2.0); // unpin: leave the window as the run found it
|
||||
// **The scope window's own layout editor**: the toggle builds its buttons and a `+` adds a
|
||||
// figure row -- the arrangement of a view is the view's.
|
||||
set_scope_layout_editing(*a, *a->scopes[1], true);
|
||||
const std::size_t layout_buttons = a->scopes[1]->layout_buttons.size();
|
||||
const int rows_before = a->scopes[1]->rows->row_count();
|
||||
if (layout_buttons > static_cast<std::size_t>(rows_before)) {
|
||||
g_signal_emit_by_name(a->scopes[1]->layout_buttons[static_cast<std::size_t>(rows_before)],
|
||||
"clicked");
|
||||
}
|
||||
const int rows_after = a->scopes[1]->rows->row_count();
|
||||
set_scope_layout_editing(*a, *a->scopes[1], false);
|
||||
std::fprintf(stderr, "[scope-probe] layout buttons=%zu rows=%d->%d\n", layout_buttons,
|
||||
rows_before, rows_after);
|
||||
// **Closing remembers, the menu reopens.** A closed window's arrangement stays in the
|
||||
// session (`closed_windows`), a launch never opens it, and the *Reopen closed windows* entry
|
||||
// brings it back -- so this leg closes the empty window, reads what was remembered, and
|
||||
// clicks the entry.
|
||||
const std::string closed_name = a->scopes[1]->name;
|
||||
const std::size_t scopes_before = a->scopes.size();
|
||||
gtk_window_destroy(GTK_WINDOW(a->scopes[1]->window));
|
||||
const std::size_t remembered = a->closed_windows.size();
|
||||
const bool remembered_name = remembered > 0 && a->closed_windows.back().name == closed_name &&
|
||||
!a->closed_windows.back().rows.empty();
|
||||
const std::size_t scopes_after_close = a->scopes.size();
|
||||
if (a->reopen_scopes_item != nullptr) {
|
||||
g_signal_emit_by_name(a->reopen_scopes_item, "clicked");
|
||||
}
|
||||
const std::size_t scopes_after = a->scopes.size();
|
||||
const int back_rows = scopes_after > scopes_after_close ? a->scopes.back()->rows->row_count() : 0;
|
||||
const std::string back_slot =
|
||||
scopes_after > scopes_after_close && a->scopes.back()->rows->row_count() > 0
|
||||
? a->scopes.back()->rows->row(0).signal_name[0]
|
||||
: std::string();
|
||||
std::fprintf(stderr,
|
||||
"[scope-probe] reopen scopes=%zu->%zu(closed)->%zu remembered=%zu name=%d "
|
||||
"rows=%d slot0='%s' left=%zu\n",
|
||||
scopes_before, scopes_after_close, scopes_after, remembered,
|
||||
remembered_name ? 1 : 0, back_rows, back_slot.c_str(),
|
||||
a->closed_windows.size());
|
||||
std::fflush(stderr);
|
||||
// **The instruments are on every window**: the cursors, the gate band and the trigger
|
||||
// marker are written from the main window's one owner (the next tick's
|
||||
// `sync_scope_instruments()`), so the states below are asked for on the main and read on
|
||||
// the scope.
|
||||
a->rows->set_cursors_visible(true);
|
||||
a->rows->set_cursor_mode(RowSet::CursorMode::Both);
|
||||
a->rows->set_cursors(-1.0, 0.0);
|
||||
a->rows->set_gate(-1.2, -0.8);
|
||||
a->rows->set_gate_visible(true);
|
||||
a->rows->set_trigger_marker(0.0, true);
|
||||
// A real measurement on 'sine' with `show_on_plot`: the marks path is the one a user's
|
||||
// measurement takes (`update_measures()` -> `place_measure_marks()` -> the windows), so a
|
||||
// window that plots 'sine' must draw its ruler and one that does not must draw none.
|
||||
{
|
||||
Measure m;
|
||||
m.signal = "sine";
|
||||
m.type = MeasureType::Top;
|
||||
m.options.show_on_plot = true;
|
||||
// Gated: `update_measures()` restates the gate's visibility every frame (it is up while
|
||||
// *any* measurement is gated), so the band is genuinely on and its bounds are the ones
|
||||
// set above rather than a value the mirror would be right to hide.
|
||||
m.options.gate = true;
|
||||
a->measures.push_back(std::move(m));
|
||||
rebuild_measures_pane(*a);
|
||||
}
|
||||
a->dbg_scope_step = 5;
|
||||
g_timeout_add(700, scope_probe_step, a);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
case 5: {
|
||||
// A frame has passed: the tick wrote the main's instruments into every window.
|
||||
int cursor_ok = 0;
|
||||
int gate_ok = 0;
|
||||
int trigger_ok = 0;
|
||||
int marks_ok = 0;
|
||||
for (const std::unique_ptr<App::ScopeWindow>& scope : a->scopes) {
|
||||
if (scope->rows == nullptr) continue;
|
||||
const bool cursors = scope->rows->cursors_visible() &&
|
||||
scope->rows->cursor_mode() == RowSet::CursorMode::Both &&
|
||||
std::fabs(scope->rows->cursor_a_x() + 1.0) < 1e-9 &&
|
||||
std::fabs(scope->rows->cursor_b_x()) < 1e-9;
|
||||
const bool gate = scope->rows->gate_visible() &&
|
||||
std::fabs(scope->rows->gate_x0() + 1.2) < 1e-9 &&
|
||||
std::fabs(scope->rows->gate_x1() + 0.8) < 1e-9;
|
||||
const bool trigger =
|
||||
scope->rows->trigger_marker_visible() == a->rows->trigger_marker_visible() &&
|
||||
std::fabs(scope->rows->trigger_marker_x() - a->rows->trigger_marker_x()) < 1e-9;
|
||||
// The mark: the window that plots 'sine' has it, one that does not has none. Which is
|
||||
// which is read off the same names.
|
||||
bool plots_sine = false;
|
||||
for (int r = 0; r < scope->rows->row_count(); ++r) {
|
||||
for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) {
|
||||
if (scope->rows->row(r).signal_name[static_cast<std::size_t>(sl)] == "sine") {
|
||||
plots_sine = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const int placed = scope->rows->measure_marks_placed();
|
||||
const bool mark = plots_sine ? placed == 1 : placed == 0;
|
||||
cursor_ok += cursors ? 1 : 0;
|
||||
gate_ok += gate ? 1 : 0;
|
||||
trigger_ok += trigger ? 1 : 0;
|
||||
marks_ok += mark ? 1 : 0;
|
||||
}
|
||||
const int n = static_cast<int>(a->scopes.size());
|
||||
std::fprintf(stderr,
|
||||
"[scope-probe] instruments windows=%d cursors=%d gate=%d trigger=%d "
|
||||
"marks=%d (main trigger on=%d x=%.3f)\n",
|
||||
n, cursor_ok, gate_ok, trigger_ok, marks_ok,
|
||||
a->rows->trigger_marker_visible() ? 1 : 0, a->rows->trigger_marker_x());
|
||||
std::fflush(stderr);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
@@ -2409,6 +2712,10 @@ gboolean source_kinds_probe(gpointer user) {
|
||||
c.set("rate", "2500");
|
||||
c.set("endian", "big");
|
||||
c.set("signals", "Trigger:uint8,Ia:float32,Wave:int16[4]");
|
||||
} else if (id == "tcps") {
|
||||
c.set("server", "10.0.0.7");
|
||||
c.set("port", "44501");
|
||||
c.set("stream", "fast");
|
||||
}
|
||||
return c;
|
||||
};
|
||||
@@ -3394,6 +3701,84 @@ gboolean flatudp_probe(gpointer user) {
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
UdpsSource* tcps_source(App* a) {
|
||||
UdpsSource* s = a->acq != nullptr ? dynamic_cast<UdpsSource*>(a->acq->source()) : nullptr;
|
||||
return s != nullptr && s->transport() == UdpsSource::Transport::Tcp ? s : nullptr;
|
||||
}
|
||||
|
||||
std::uint64_t g_tcps_frames_before = 0;
|
||||
std::uint64_t g_tcps_reconnects_before = 0;
|
||||
|
||||
} // namespace
|
||||
|
||||
/// Phase 2, 1 s after the signals were enabled: connected, the mock's time master and four channels, frames
|
||||
/// flowing, and the capture button present but disabled (the capture is of the UDP socket).
|
||||
gboolean tcps_probe_report(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
|
||||
std::string names;
|
||||
for (const SignalInfo& s : a->model->signals()) names += (names.empty() ? "" : ",") + s.name;
|
||||
const std::uint64_t frames = a->model->stats().frames.load(std::memory_order_relaxed);
|
||||
const bool ok = up && names == "time,sine,square,ramp,noise" && frames > 0;
|
||||
std::fprintf(stderr,
|
||||
"[tcps] stream: connected=%d signals=%zu names=%s frames=%" G_GUINT64_FORMAT " ok=%d\n",
|
||||
up ? 1 : 0, a->model->signals().size(), names.c_str(), frames, ok ? 1 : 0);
|
||||
std::fprintf(stderr, "[tcps] 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::fflush(stderr);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/// Phase 1: enable every signal in one structural change, report a second later.
|
||||
gboolean tcps_probe(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||
if (tcps_source(a) == nullptr) {
|
||||
std::fprintf(stderr, "[tcps] no tcps source (run with --source tcps)\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);
|
||||
g_timeout_add(1000, tcps_probe_report, a);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/// After the mock closed the connection (`--close-after`): the client must have reconnected and
|
||||
/// the frame count moved on. Phase 1 records the baseline; phase 2 polls every 100 ms until both
|
||||
/// have happened (the close falls whenever the mock's timer says, so a fixed delay would race it)
|
||||
/// and reports the last reading after 6 s.
|
||||
gboolean tcps_probe_reconnect_report(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
|
||||
const std::uint64_t frames = a->model->stats().frames.load(std::memory_order_relaxed);
|
||||
const std::uint64_t rc = a->model->stats().reconnects.load(std::memory_order_relaxed);
|
||||
const bool ok = up && frames > g_tcps_frames_before && rc > g_tcps_reconnects_before;
|
||||
static int ticks = 0;
|
||||
if (!ok && ++ticks < 60) return G_SOURCE_CONTINUE;
|
||||
std::fprintf(stderr,
|
||||
"[tcps] reconnect: connected=%d frames_before=%" G_GUINT64_FORMAT " after=%" G_GUINT64_FORMAT
|
||||
" reconnects_before=%" G_GUINT64_FORMAT " after=%" G_GUINT64_FORMAT " ok=%d\n",
|
||||
up ? 1 : 0, g_tcps_frames_before, frames, g_tcps_reconnects_before, rc, ok ? 1 : 0);
|
||||
std::fflush(stderr);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
gboolean tcps_probe_reconnect(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||
g_tcps_frames_before = a->model->stats().frames.load(std::memory_order_relaxed);
|
||||
g_tcps_reconnects_before = a->model->stats().reconnects.load(std::memory_order_relaxed);
|
||||
g_timeout_add(100, tcps_probe_reconnect_report, a);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
gboolean pan_probe(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
if (a->window == nullptr || a->rows->row_count() <= 0) return G_SOURCE_REMOVE;
|
||||
|
||||
@@ -288,6 +288,48 @@ void fill(App& app, const std::string& name, GtkWidget* page) {
|
||||
&app);
|
||||
}
|
||||
gtk_box_append(GTK_BOX(page), rows_box);
|
||||
|
||||
// The scope windows: each is a view with its own arrangement, and a signal is plotted there
|
||||
// exactly as it is here -- one group per window (named, so two windows never blur into one),
|
||||
// one button per plot of that window's figure. The buttons read *that* window's rows for
|
||||
// "already on this plot", and a click lands in the window's own row set.
|
||||
for (const std::unique_ptr<App::ScopeWindow>& scope : app.scopes) {
|
||||
if (scope->rows == nullptr || scope->rows->row_count() <= 0) continue;
|
||||
GtkWidget* scope_head = gtk_label_new(("In \"" + scope->name + "\"").c_str());
|
||||
gtk_widget_add_css_class(scope_head, "heading");
|
||||
gtk_widget_set_halign(scope_head, GTK_ALIGN_START);
|
||||
gtk_box_append(GTK_BOX(page), scope_head);
|
||||
GtkWidget* scope_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
|
||||
gtk_widget_set_name(scope_box, "udpscope-scope-plot-on");
|
||||
for (int r = 0; r < scope->rows->row_count(); ++r) {
|
||||
GtkWidget* b = text_button(scope->rows->plot_label(r).c_str(), scope_box);
|
||||
tag(b, index);
|
||||
g_object_set_data(G_OBJECT(b), "udpscope-row", GINT_TO_POINTER(r));
|
||||
g_object_set_data(G_OBJECT(b), "udpscope-scope", scope.get());
|
||||
bool on_this_row = false;
|
||||
for (int slot = 0; slot < RowSet::kSlotsPerRow; ++slot) {
|
||||
if (scope->rows->row(r).signal_name[static_cast<std::size_t>(slot)] == sig.name) {
|
||||
on_this_row = true;
|
||||
}
|
||||
}
|
||||
gtk_widget_set_sensitive(b, !on_this_row);
|
||||
gtk_widget_set_tooltip_text(b, on_this_row ? "already on this plot"
|
||||
: "draw this signal in this window's plot");
|
||||
g_signal_connect(b, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
auto* scope = static_cast<App::ScopeWindow*>(
|
||||
g_object_get_data(G_OBJECT(b), "udpscope-scope"));
|
||||
if (scope == nullptr) return;
|
||||
outlive_the_rebuild(GTK_WIDGET(b));
|
||||
assign_signal_to_scope(*a, *scope, tagged(b),
|
||||
GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b),
|
||||
"udpscope-row")));
|
||||
}),
|
||||
&app);
|
||||
}
|
||||
gtk_box_append(GTK_BOX(page), scope_box);
|
||||
}
|
||||
|
||||
GtkWidget* off = text_button("Remove from plots", page);
|
||||
gtk_widget_set_halign(off, GTK_ALIGN_START);
|
||||
gtk_widget_set_sensitive(off, plotted);
|
||||
|
||||
+82
-13
@@ -505,24 +505,26 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
|
||||
app.updating_panels = false;
|
||||
}
|
||||
|
||||
bool assign_signal_to_row(App& app, std::size_t signal, int row) {
|
||||
if (signal >= app.model->signals().size()) return false;
|
||||
|
||||
const auto notice = [&](const std::string& what) {
|
||||
/// The drop's one-line report, wherever the drop landed: the notice bar and the log, because a
|
||||
/// refusal a reader cannot act on is only half an answer.
|
||||
void drop_notice(App& app, const std::string& what) {
|
||||
set_notice(app, what, 4);
|
||||
std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str());
|
||||
std::fflush(stderr);
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolves a dropped signal to an acquired channel, enabling the signal when it has to be: the
|
||||
/// channel index and its display name, or -1 when the signal cannot be acquired (a multi-element
|
||||
/// signal has no channel to bind). Shared by the main window's drop and the scope windows'.
|
||||
int resolve_drop_channel(App& app, std::size_t signal, std::string* name) {
|
||||
// The dropped signal's channels are the ones the model built for it; take the
|
||||
// first, or the first element if the signal is a vector.
|
||||
const std::vector<ChannelInfo>& channels = app.model->channels();
|
||||
int channel = -1;
|
||||
std::string name;
|
||||
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||
if (channels[i].signal == signal) {
|
||||
channel = static_cast<int>(i);
|
||||
name = channels[i].name;
|
||||
*name = channels[i].name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -533,13 +535,21 @@ bool assign_signal_to_row(App& app, std::size_t signal, int row) {
|
||||
for (std::size_t i = 0; i < again.size(); ++i) {
|
||||
if (again[i].signal == signal) {
|
||||
channel = static_cast<int>(i);
|
||||
name = again[i].name;
|
||||
*name = again[i].name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
bool assign_signal_to_row(App& app, std::size_t signal, int row) {
|
||||
if (signal >= app.model->signals().size()) return false;
|
||||
|
||||
std::string name;
|
||||
const int channel = resolve_drop_channel(app, signal, &name);
|
||||
if (channel < 0) {
|
||||
notice("cannot acquire '" + app.model->signals()[signal].name +
|
||||
drop_notice(app, "cannot acquire '" + app.model->signals()[signal].name +
|
||||
"' (multi-element signals are not acquired)");
|
||||
return false;
|
||||
}
|
||||
@@ -554,9 +564,9 @@ bool assign_signal_to_row(App& app, std::size_t signal, int row) {
|
||||
if (slot < 0) {
|
||||
// Say what to do about it. A refusal a reader cannot act on is only half an answer: the two ways
|
||||
// out are another plot or another row.
|
||||
notice("plot " + app.rows->plot_label(row) +
|
||||
" holds four signals already: drop it on another plot, or add a row with the layout "
|
||||
"editor (L)");
|
||||
drop_notice(app, "plot " + app.rows->plot_label(row) +
|
||||
" holds four signals already: drop it on another plot, or add a row with "
|
||||
"the layout editor (L)");
|
||||
return false;
|
||||
}
|
||||
// The slot remembers the *signal* (and element), never the channel index: the
|
||||
@@ -567,10 +577,69 @@ bool assign_signal_to_row(App& app, std::size_t signal, int row) {
|
||||
schedule_layout_save(app);
|
||||
app.relayout_pending = true;
|
||||
rebuild_signal_panels(app); // the row's colour chip now has a trace to name
|
||||
notice("plotting '" + name + "' on plot " + app.rows->plot_label(row));
|
||||
drop_notice(app, "plotting '" + name + "' on plot " + app.rows->plot_label(row));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool assign_signal_to_scope(App& app, App::ScopeWindow& scope, std::size_t signal, int row) {
|
||||
if (scope.rows == nullptr || signal >= app.model->signals().size()) return false;
|
||||
if (row < 0 || row >= scope.rows->row_count()) return false;
|
||||
std::string name;
|
||||
const int channel = resolve_drop_channel(app, signal, &name);
|
||||
if (channel < 0) {
|
||||
drop_notice(app, "cannot acquire '" + app.model->signals()[signal].name +
|
||||
"' (multi-element signals are not acquired)");
|
||||
return false;
|
||||
}
|
||||
int slot = -1;
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||
if (!scope.rows->slot_bound(row, s)) {
|
||||
slot = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (slot < 0) {
|
||||
drop_notice(app, scope.name + " plot " + scope.rows->plot_label(row) + " holds four signals "
|
||||
"already: drop it on another plot of that window");
|
||||
return false;
|
||||
}
|
||||
// The slot remembers the *signal* (and element), never the channel index -- the same rule the
|
||||
// main window's rows follow, for the same renumbering reason.
|
||||
const std::vector<SignalInfo>& signals = app.model->signals();
|
||||
const SignalInfo& sig = signals[signal];
|
||||
scope.rows->assign(row, slot, sig.name, name, signal_color_index(signals, signal));
|
||||
// The rebuild and the area's re-point are the window's own frame tick's (a drop arrives from
|
||||
// inside this widget, and a synchronous rebuild would reset the drag controller under it). The
|
||||
// signal page is refreshed now: its *Plot on* buttons read the scope's rows for "already on".
|
||||
signal_pages_sync(app);
|
||||
drop_notice(app, "plotting '" + name + "' on " + scope.name + " plot " +
|
||||
scope.rows->plot_label(row));
|
||||
return true;
|
||||
}
|
||||
|
||||
gboolean on_scope_drop_channel(GtkDropTarget* target, const GValue* value, double x, double y,
|
||||
gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
auto* scope =
|
||||
static_cast<App::ScopeWindow*>(g_object_get_data(G_OBJECT(target), "udpscope-scope"));
|
||||
if (scope == nullptr || scope->area == nullptr || scope->rows == nullptr) return FALSE;
|
||||
if (!G_VALUE_HOLDS_STRING(value)) return FALSE;
|
||||
const char* text = g_value_get_string(value);
|
||||
if (text == nullptr || *text == '\0') return FALSE;
|
||||
char* end = nullptr;
|
||||
const unsigned long long parsed = std::strtoull(text, &end, 10);
|
||||
if (end == nullptr || *end != '\0') return FALSE;
|
||||
const std::size_t signal = static_cast<std::size_t>(parsed);
|
||||
// Where the drop landed decides the row *of this window*: one area covers its figure's cells.
|
||||
const int row = std::clamp(static_cast<int>(scope->area->row_at(x, y)), 0,
|
||||
scope->rows->row_count() - 1);
|
||||
if (!assign_signal_to_scope(*app, *scope, signal, row)) return FALSE;
|
||||
if (plot::input::Controller* c = scope->area->row_controller(static_cast<plot::u32>(row))) {
|
||||
c->on_key(plot::input::KeyEvent{plot::input::kKeyEscape, 0, true});
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y,
|
||||
gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
|
||||
+2
-2
@@ -443,8 +443,8 @@ constexpr TourStep kTourSteps[] = {
|
||||
{"plot", nullptr, "The plot",
|
||||
"Every signal you have switched on is drawn here, all plots sharing one time axis -- and the "
|
||||
"plots of a split row share one y as well. The wheel zooms, a drag pans, and a right-click on "
|
||||
"a plot opens its own menu: fit, full screen (the button also comes out in a figure's corner "
|
||||
"as the pointer nears it), a second window showing that figure, and the layout editor, which "
|
||||
"a plot opens its own menu: fit, full screen and detach (the corner buttons come out as the "
|
||||
"pointer nears them), a second window showing that figure, and the layout editor, which "
|
||||
"splits a row into up to four plots.",
|
||||
&App::area_overlay},
|
||||
{"badges", nullptr, "The badges",
|
||||
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MIT
|
||||
# SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
"""A minimal MARTe2 TCPStreamer / TCPStreamService producer, for exercising udpscope.
|
||||
|
||||
Listens on `--port`, serves one client at a time with the same frames as
|
||||
mock_udpstreamer.py (CONFIG, then Accumulate DATA packets of sine/square/ramp/noise), each
|
||||
framed UDPS-over-TCP. Without `--stream` it behaves like a standalone TCPStreamer: the
|
||||
stream starts at once and anything the client sends is ignored. With `--stream NAME` it
|
||||
behaves like a TCPStreamService: the client must open with a SUBSCRIBE frame naming NAME
|
||||
within 5 s, otherwise the connection is closed.
|
||||
|
||||
./tools/mock_tcpstreamer.py --port 44500 --stream fast --close-after 6
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import select
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import mock_udpstreamer as m # noqa: E402
|
||||
|
||||
PKT_SUBSCRIBE = 5
|
||||
HEADER = 17
|
||||
|
||||
|
||||
def read_exact(conn, n, deadline):
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
left = deadline - time.monotonic()
|
||||
if left <= 0:
|
||||
return None
|
||||
r, _, _ = select.select([conn], [], [], left)
|
||||
if not r:
|
||||
return None
|
||||
chunk = conn.recv(n - len(buf))
|
||||
if not chunk:
|
||||
return None
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
def subscribed_to(conn, want):
|
||||
"""True once a SUBSCRIBE frame naming `want` arrived within 5 s."""
|
||||
deadline = time.monotonic() + 5.0
|
||||
head = read_exact(conn, HEADER, deadline)
|
||||
if head is None:
|
||||
return False
|
||||
magic, ptype, _, _, _, plen = struct.unpack("<IBIHHI", head)
|
||||
if magic != m.MAGIC or ptype != PKT_SUBSCRIBE or not 0 < plen <= 4096:
|
||||
return False
|
||||
name = read_exact(conn, plen, deadline)
|
||||
return name is not None and name.decode(errors="replace") == want
|
||||
|
||||
|
||||
def serve(conn, args):
|
||||
dt = 1.0 / args.rate
|
||||
period = args.batch / args.rate
|
||||
conn.sendall(m.build_config(args.rate, m.TIME_FULL_ARRAY, "V"))
|
||||
counter, t_stream = 1, 0.0
|
||||
started = time.monotonic()
|
||||
next_send = started
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
if args.close_after > 0 and now - started >= args.close_after:
|
||||
print("mock tcpstreamer: closing the connection (--close-after)", flush=True)
|
||||
return
|
||||
if now < next_send:
|
||||
time.sleep(min(next_send - now, 0.002))
|
||||
continue
|
||||
pkt = m.build_data(counter, t_stream, dt, args.batch, args.freq)
|
||||
if not (args.drop_every > 0 and counter % args.drop_every == 0):
|
||||
conn.sendall(pkt)
|
||||
counter += 1
|
||||
t_stream += args.batch * dt
|
||||
next_send += period
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", type=int, default=44500)
|
||||
ap.add_argument("--stream", default="", help="TCPStreamService mode: require SUBSCRIBE of this name")
|
||||
ap.add_argument("--rate", type=float, default=10000.0, help="samples per second")
|
||||
ap.add_argument("--batch", type=int, default=100, help="samples per packet")
|
||||
ap.add_argument("--freq", type=float, default=5.0, help="waveform frequency, Hz")
|
||||
ap.add_argument("--close-after", type=float, default=0.0, metavar="S",
|
||||
help="close each connection after S seconds (the client must reconnect); 0 = never")
|
||||
ap.add_argument("--drop-every", type=int, default=0, metavar="N",
|
||||
help="skip sending every Nth packet but advance the counter (a server-side drop); 0 = never")
|
||||
args = ap.parse_args()
|
||||
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(("0.0.0.0", args.port))
|
||||
srv.listen(4)
|
||||
print("mock tcpstreamer: listening on :%d (%s)" % (
|
||||
args.port, "service stream '%s'" % args.stream if args.stream else "standalone"), flush=True)
|
||||
while True:
|
||||
conn, addr = srv.accept()
|
||||
print("mock tcpstreamer: client %s:%d" % addr, flush=True)
|
||||
try:
|
||||
if args.stream and not subscribed_to(conn, args.stream):
|
||||
print("mock tcpstreamer: no valid SUBSCRIBE for '%s'; closing" % args.stream, flush=True)
|
||||
continue
|
||||
if args.stream:
|
||||
print("mock tcpstreamer: SUBSCRIBE '%s'" % args.stream, flush=True)
|
||||
serve(conn, args)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
print("mock tcpstreamer: client went away", flush=True)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user