17 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 39f82ade31 udpsclient: size the TCP receive buffer from max_packet_bytes so large MARTe frames are not dropped
A TCP frame is never fragmented; a real TCPStreamer cycle (240 KB) exceeded the UDP datagram
limit and made the client reconnect forever, seeing CONFIG but never DATA.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-06 14:07:54 +01:00
Martino Ferrari a65f953f17 docs: the TCPStreamer source kind, the client's TCP mode, and its probes 2026-11-06 13:58:48 +01:00
Martino FerrariandClaude Opus 4.6 3dad65c487 probes: the TCPStreamer kind end to end (standalone, service, reconnect)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-06 13:54:44 +01:00
Martino FerrariandClaude Opus 4.6 f27bb17669 sources: a TCPStreamer kind (standalone or TCPStreamService) on the UDPS client's TCP mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-06 13:47:23 +01:00
Martino FerrariandClaude Opus 4.6 7b8c932ab7 udpsclient: a TCP transport mode (SUBSCRIBE, no CONNECT) and a TCP mock server
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-06 13:44:26 +01:00
Martino Ferrari 21b1945769 docs: TCPStreamer source kind spec and plan 2026-11-06 13:41:31 +01:00
martino d47704c6ec Merge pull request 'Gated measurements' (#2) from gated-measurements into main
Reviewed-on: #2
2026-11-06 12:30:43 +01:00
Martino Ferrari 91ca29d181 scope windows: the same instruments as the main window
A scope or detached window drew its traces and nothing else: no cursors, no gate band, no trigger
marker, no measurement marks. They are not per-window state to be recreated -- the cursors are one
pair of times, the gate one band, the trigger one instant -- so the main window's row set stays the
**owner** and every window is written from it.

`sync_scope_instruments()` runs in the main tick after the main's own `sync_cursors()`/
`sync_gate()`: a gesture inside a scope window is read back from *that* window's controllers first
(those two calls only adopt while their own plot holds the grab, which is exactly "the drag
happened here"), adopted by the owner, and then every window -- the dragged one included -- is
written from the owner with the same cursor pair, mode and visibility, gate bounds and visibility,
and trigger marker. `RowSet::set_cursors()` is the new pair setter; `trigger_marker_x()`/`_visible()`
are the getters the mirror reads. The measurement marks travel by signal name
(`RowSet::MeasureMark::signal`, filled by the measures pass), so each window finds the row that
plots the signal and draws its ruler or mark there -- or none, when it does not plot it. The search
marks are times on the window and are placed on every window by the same per-frame path, and the
crosshair-axis-labels preference is restated to open windows when it changes.

Probe: `[scope-probe] instruments windows=N cursors=N gate=N trigger=N marks=N (main trigger
on=..)`, after adding a real gated `Top` measurement on `sine` -- the gate is restated visible each
frame by `update_measures()` and marks come only from `show_on_plot` measurements, so the leg uses
the app's own path. Docs: README, the cursors invariant, and the probe readings.
2026-11-06 12:21:38 +01:00
Martino Ferrari a29f38eaa9 session: remember extra windows, and reopen them from the menu
A scope or detached window was never written to the session, and a detached figure vanished from
the layout at the next start -- the figure had been removed from the main rows, and nothing kept
the window that held it.

Each window is now a `[[window]]` block in the session -- `name`, `detached`, and its own
`[[window.plot]]` rows with the same keys the main `[[plot]]` rows carry (signals, row weight,
cell width). `collect_layout()` writes the open windows' present arrangement plus the ones already
closed; loading puts **all** of them in `App::closed_windows` and never opens one: a window is a
view the user closed, and a launch that tiled windows onto the desktop would take that decision
for them (a tiling WM would tile them straight away). The overflow menu gains **Reopen closed
windows (N)**, which opens every remembered window through `open_scope_window_from_state()` -- the
one window constructor, then its rows, weights and signals dealt into its row set.

Closing a window records it, by name (a close/reopen cycle replaces its entry rather than
multiplying them), and schedules the save; **attaching** records nothing, because that window's
figure is on the main window again. A detached figure therefore survives a quit through its
window's entry.

Probe: `[scope-probe] loaded closed=N scopes=0` (remembered, none opened) and `reopen
scopes=..->.. remembered=.. left=0` (close, remember, the menu item's own click reopens).
Docs: README, the Mallard layout page, the probe readings and the session invariant.
2026-11-06 12:06:21 +01:00
Martino Ferrari 0c9333858c scope windows: their own layout editor, and the same shortcuts
A detached window is a view with an arrangement of its own -- and there was no way to change it:
the layout editor was the main window's, and a second window's rows could not be added or removed.

Every scope window now carries its own editor: `L` (or the grid toggle in its corner, revealed by
the same proximity rule as the attach button) shows the main editor's `+` / `×` / Done controls,
and they act on **that window's** row set (`set_scope_layout_editing()`, every button tagged with
its window), so a window holds as many figures as the user gives it and the two editors cannot
move each other's rows. Nothing a scope window arranges is written to the main window's session:
the windows themselves are not persisted.

The keyboard: each scope window gets a `GtkEventControllerKey` running the application's one
accelerator table -- `L` acts on the window the key arrived in, every other action reaches the
application through `run_app_action()`. The plot's own keys ('f', the arrows, Escape) already
worked there, through its plot controller.

The window struct now always builds its overlay (the corner controls' host); the attach button
stays detached-only, and both corner toggles step aside while the editor is up (Done is the way
out).

Probe: `[scope-probe] layout buttons=3 rows=1->2` -- the toggle builds 2*rows+1 buttons and a
boundary `+` adds a row. `L` in a real scope window verified by OCR ("Done" appears, the row's
`×` at its left). Docs: README, the Mallard layout page, the probe readings, and the layout
invariant (a view's arrangement is the view's).
2026-11-06 10:54:50 +01:00
Martino Ferrari ecdbf4f0e1 scope windows: mirror a pin as a range, and restore the tick rebuild
**The blank scope window in trigger mode.** A held event pins the main window to
`[-pre, +post]`, and the tick mirrors that pin onto every scope window. It passed
`pinned_range().min/max` to `pin_event_window()`, which takes **spans** (`pre, post`): `min` is
signed, so `pre` came out negative and the group range became `[+pre, +post]` — an inverted x
window. The plotter then had nothing to draw: no trace, no x-axis labels, an empty plot that
looked like a broken window. `RowSet::pin_display_range(x0, x1)` states the pin as the range a
mirroring view actually has, and the tick uses it; live mode was never affected, which is why the
defect only showed under the trigger.

Also restores the scope-window `rebuild_if_dirty()` in the tick, which a reproduction experiment
had left commented out (`REPRO-TEMP`) and a later commit carried: a window opened empty and filled
by a drop kept the figure it was built with.

Probe: `[scope-probe] pin_mirror=1 main=[..] scope=[..] x=[..]` pins the main window, reads the
scope's pin and plot x range a frame later, and reads `0` for an inverted or mismatched pin.
Docs: probe readings and the trigger/pinning invariant (range vs spans).
2026-11-06 10:39:10 +01:00
Martino Ferrari 2279e2140a scope windows: their own frame pump, and a diagnosis that names the GSK renderer
A second toplevel's frame clock is not the main window's: the tick binds, queues a render and
mirrors the span, but with nothing asking GTK for frames on *that* window the area can sit with
its queue full and present nothing -- a blank plot. Every scope window now carries a tick
callback on its own area (`gtk_gl_area_queue_render` per frame), which is the request that keeps
its frame clock running.

The "cannot paint" diagnosis now names the window's GSK renderer (`[GSK renderer: GskVulkanRenderer]`)
and says to restart with `GSK_RENDERER=gl` when it is the Vulkan one -- the interaction between
the Vulkan GSK renderer and an embedded `GtkGLArea` in a second toplevel is a known trouble spot,
and a tiling WM has no title bar to carry a title, so the sentence goes to stderr and the status
bar instead.
2026-11-06 10:09:28 +01:00
Martino Ferrari fa2d0f12fc scope windows: Fit X axis / Fit all in the menu, and a window that cannot paint says so
The scope menu carried a y fit only: with one shared time span, the x fit *is* the main window's
fit, and the next frame mirrors the result back -- so `Fit X axis` and `Fit all` now sit in the
menu beside the y entries and reset the shared range.

And a blank plot becomes a sentence: a scope window whose GL context failed, or whose area has
painted no frame two seconds after opening, writes the reason into its own title and to stderr
(`[scope] window 'plot 1' is not drawing: GL context failed: ...`). A blank rectangle is
indistinguishable from a failed context or an area that never got a frame, and the report "the
plot is not there, no x axis" is exactly what this answers: if the title stays unchanged, the
area is painting and the question moves to the compositor presenting the second window.
2026-11-06 09:03:54 +01:00
Martino Ferrari 2924f7627d scope windows: a right-click menu of their own
A scope window is a view with its own arrangement, but its plot had no menu: every right-click
went nowhere, because the main window's row menu acts on the main window's rows.

`on_scope_right_click()` wires each scope window's GL area (beside its drop target) to a menu
that acts on **that window's** row set: y axis auto scale, y axis fixed range (the main menu's
dialog, aimed at the window's rows through `FixedRangeContext::rows`), fit, and one
*Remove '<signal>'* per trace on the plot. A detached window's menu also carries **Attach back
to the main window**. What it deliberately does not carry is an x fit: the window follows the
main window's time span, so an x fit here would be undone by the next frame's mirror.

`remove_trace()` and `FixedRangeContext` are generalized to take the row set they act on (the
main window passes its own). Probe: `[scope-probe] rclick=1` asserts the click gesture is wired
per window; the menu build is exercised by the real right-clicks the smoke makes.
2026-11-06 08:55:29 +01:00
Martino Ferrari b90a99ec39 scope probe: read the windows' own GL account, and drop into the empty window
`[scope-probe]` grows the two readings a "blank scope window" report needs before it can be
blamed: `empty_drop=`/`empty_slot0=` (the same drop onto the empty window, the user's own
path) and `gl copy=/gl empty=/gl detached ok= frames=` -- each window's adapter context
status and staged-frame count. A scope whose context failed draws nothing at all: no axes,
no traces, exactly the "blank plot widget, no x axis" report -- and that is a driver/context
finding, not a layout one. The detached window is left open by the probe, so a run's
screenshots can answer "does the detached figure draw" with pixels.
2026-11-06 08:19:50 +01:00
Martino Ferrari 5ed75eb470 layout: detach a figure to its own window, and attach it back
Beside each figure row's full-screen toggle -- same corner, same show-on-approach reveal, same
one-figure/never-maximized rules -- a **detach** button moves the figure into a window of its
own: copied into a new scope window (titled "detached") and removed from the main window's rows,
which then save the change. The detached window is the one scope window with an **attach**
control: an overlay wrapping its GL area, a button in the top-right corner revealed by the same
pointer-proximity rule from the overlay's own motion controller, and one click appends the
figure back to the main window's rows (cells, weights, every slot's signal in its colour) and
closes the window.

`detach_figure()` / `attach_scope_window()`; the reveal and placement share the maximize
buttons' geometry and pointer state, per row. A detached window's rows participate everywhere a
scope window's do (drops, the signal page's *Plot on* groups), which the previous commit gave
them.

Probe: `[maximize-probe]` now counts the detach buttons (`detach_single=0` for one figure,
`detach=2` for two, absent while maximized) and reads their corner reveal
(`detach_near=1 detach_other=0`, the pointer parked on the detach button's own centre -- the
strictest zone of the pair). Real-pointer smoke: hover reveals the pair, clicking detach opens
the window, clicking attach closes it with the figure back. Docs: README, the Mallard layout
page, the probe readings, the layout invariant, and the tour's plot stop.
2026-11-05 23:35:26 +01:00
Martino Ferrari 51b4fad386 scope windows take drops, and the signal page lists them
The second windows were read-only by accident: nothing accepted a drop on them, and the
signal page's *Plot on* buttons knew only the main window's rows.

- **Drop target per scope window**: the same string payload a badge drags (the signal index),
  handled by `on_scope_drop_channel()` -- the target carries its own window, the row is that
  window's cell under the pointer, and `assign_signal_to_scope()` lands the signal in the
  window's own row set (enabling an unacquired signal first, exactly as the main drop does).
  The rebuild and the area's re-point are the window's next frame in the tick: a drop arrives
  from inside the very widget it rearranges, and a synchronous rebuild would reset the drag
  controller under it.
- **Plot on, per window**: the signal page carries one named group per scope window ("In
  \"plot 2\""), one button per plot of that window, sensitive on that window's own rows. A
  window closing refreshes the pages in its destroy handler, before the deferred delete could
  dangle a button's scope pointer.
- Probe: `[scope-probe]` drops through the wired handler (`drop=1 slot1='square'`) and counts
  the page's groups (`page_groups=2`); the section documents that it must not run with
  `UDPSCOPE_ENABLE_ALL_MS` -- the enable-all probe's second leg clears the front plot, which
  is the slot this probe copies. README, help and the layout invariant follow.
2026-11-05 19:37:42 +01:00
34 changed files with 2956 additions and 86 deletions

No files matched your search

+1
View File
@@ -241,6 +241,7 @@ add_executable(udpscope
src/source/registry.cpp src/source/registry.cpp
src/source/udps_source.cpp src/source/udps_source.cpp
src/source/udps_kind.cpp src/source/udps_kind.cpp
src/source/tcps_kind.cpp
src/source/probe_source.cpp src/source/probe_source.cpp
src/source/e1608/e1608_link.cpp src/source/e1608/e1608_link.cpp
src/source/e1608/e1608_device.cpp src/source/e1608/e1608_device.cpp
+56 -1
View File
@@ -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 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 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 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 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 `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 **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 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 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 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. mode or the span.
**The window.** The plot is the window. Under it sits a fixed-height **badge bar** (36 px). Its left **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 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. 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 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 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. 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 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 not invent samples in `accumulate` mode. The info popup and the CONFIG dump on
stderr report the element counts. 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 - **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 of the event-span selection; the ring holds far more, so with trigger mode off the
rest is scrollable history. rest is scrollable history.
+68 -5
View File
@@ -174,6 +174,7 @@ struct udps_client {
char server_addr[128]; char server_addr[128];
char mcast_group[128]; char mcast_group[128];
char iface_addr[128]; char iface_addr[128];
char stream_name[256];
struct sockaddr_in server_sa; struct sockaddr_in server_sa;
int udp_fd; /**< Unicast receive socket, or joined multicast socket. */ int udp_fd; /**< Unicast receive socket, or joined multicast socket. */
@@ -197,6 +198,7 @@ struct udps_client {
size_t vals_cap; size_t vals_cap;
uint8_t *rxbuf; uint8_t *rxbuf;
size_t rxbuf_cap; /**< bytes allocated for rxbuf */
udps_held_t held[UDPS_REORDER_SLOTS]; udps_held_t held[UDPS_REORDER_SLOTS];
size_t held_bytes; /**< payload bytes currently held */ size_t held_bytes; /**< payload bytes currently held */
@@ -1149,9 +1151,50 @@ static int connect_multicast(udps_client_t *c) {
return 0; 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) { 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)
: connect_unicast(c); : (c->mcast_group[0] != '\0') ? connect_multicast(c)
: connect_unicast(c);
if (rc != 0) { if (rc != 0) {
c->disconnect_t = now_mono(); c->disconnect_t = now_mono();
return rc; return rc;
@@ -1174,7 +1217,10 @@ static void do_disconnect(udps_client_t *c, const char *why) {
if (!c->connected) { if (!c->connected) {
return; return;
} }
(void)send_control(c, UDPS_PKT_DISCONNECT); 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); close_sockets(c);
c->connected = 0; c->connected = 0;
c->disconnect_t = now_mono(); 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) { if (udps_parse_header(c->rxbuf, UDPS_HEADER_SIZE, &h) != 0) {
return -1; /* desynchronised: the stream cannot be resynced */ 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; return -1;
} }
if (h.payload_bytes > 0u && if (h.payload_bytes > 0u &&
@@ -1267,17 +1313,28 @@ udps_client_t *udps_client_create(const udps_client_config_t *cfg) {
if (c == NULL) { if (c == NULL) {
return NULL; return NULL;
} }
if (cfg->use_tcp && cfg->stream_name != NULL && strlen(cfg->stream_name) > 255u) {
free(c);
return NULL;
}
c->cfg = *cfg; c->cfg = *cfg;
c->udp_fd = -1; c->udp_fd = -1;
c->tcp_fd = -1; c->tcp_fd = -1;
copy_str(c->server_addr, sizeof c->server_addr, cfg->server_addr); 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->mcast_group, sizeof c->mcast_group, cfg->multicast_group);
copy_str(c->iface_addr, sizeof c->iface_addr, cfg->interface_addr); 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 /* 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. */ * caller and the copies above are what the client works from. */
c->cfg.server_addr = c->server_addr; c->cfg.server_addr = c->server_addr;
c->cfg.multicast_group = (c->mcast_group[0] != '\0') ? c->mcast_group : NULL; 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.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) { if (c->cfg.max_packet_bytes == 0u) {
c->cfg.max_packet_bytes = 1u * 1024u * 1024u; 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; 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) { if (c->rxbuf == NULL) {
free(c); free(c);
return NULL; return NULL;
+16 -1
View File
@@ -52,7 +52,8 @@ enum {
UDPS_PKT_CONFIG = 1, /**< Server -> client: signal metadata. */ UDPS_PKT_CONFIG = 1, /**< Server -> client: signal metadata. */
UDPS_PKT_ACK = 2, /**< Client -> server: keepalive. */ UDPS_PKT_ACK = 2, /**< Client -> server: keepalive. */
UDPS_PKT_CONNECT = 3, /**< Client -> server: open a session. */ 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). */ /** Sample type codes (udps_signal_t::type_code). */
@@ -279,6 +280,20 @@ typedef struct {
* the drop-on-late behaviour. Default 0.020. * the drop-on-late behaviour. Default 0.020.
*/ */
double reorder_window_s; 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; } udps_client_config_t;
/** Fills @p cfg with the defaults documented in Docs/UDPS-C-Client.md. */ /** Fills @p cfg with the defaults documented in Docs/UDPS-C-Client.md. */
+9 -1
View File
@@ -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. 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 - 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. 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). `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 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. 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 - A kind's capability panels are pages of the slide-over (`slideover_add_page`), named `source-<id>`. A disconnected source
+24 -2
View File
@@ -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`; (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.) 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: `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 - `--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 `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 (`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 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 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 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. 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 `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 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. 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, 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 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=`. 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 `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 there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the
+17 -1
View File
@@ -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 (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 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 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 - **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 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 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 visible no-op is worse than none. The button reappears while a row *is* maximized, because
there it is the way back. 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 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 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 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 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 component, the same rule the configuration files use, so a name typed with a slash cannot
leave the directory. 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 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 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. 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`.
+111 -7
View File
@@ -1422,19 +1422,28 @@ Measured output (viewport pixels; the absolute numbers are the headless window's
## `[maximize-probe]` ## `[maximize-probe]`
`UDPSCOPE_PROBE_MAXIMIZE_MS`. The one-click **full-screen row** toggle, exercised through the `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 `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 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 **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: (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] 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 [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 - `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 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). 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, - `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 and `buttons=` one overlay toggle per figure row (the toggles are on screen whether or not
the layout editor is up). 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 `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 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] 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 - `count=2` is both windows having been created and retained; `windows=1` each being a visible
top-level window. 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 `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. 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 - `empty_ok=1` is the `-1` call producing an empty row, which is the other entry point the
overflow menu uses. 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]` ## `[search-probe]`
@@ -2051,8 +2117,9 @@ and `regained` reads 400 ms later.
## `[source-kinds]` ## `[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 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 e1608: round_trip=1 valid=1
[source-kinds] form flatudp: round_trip=1 valid=1 [source-kinds] form flatudp: round_trip=1 valid=1
[source-kinds] form probe: 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` 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. 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. 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 - `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 - `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. 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 - `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 - `resumed: ok=1`: `Connected` was raised again and frames resumed. `connected=0` here means the source never
recovered after the sender came back. 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]` ## `[prefs-probe]`
``` ```
+17 -1
View File
@@ -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` - `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 -- 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. 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`. reading's own entry is in `probe-readings.md`.
- `UDPSCOPE_PROBE_E1608=1500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source e1608` against - `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 `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. - **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 - `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). 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 - `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 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=`. 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.
+9 -3
View File
@@ -77,12 +77,18 @@
nothing is saved, nothing is dropped, and the rows' traces, colours and cursors come back 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 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</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 <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 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 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 <gui>New scope window</gui> opens the same kind of window with an empty figure. A signal
closes that view only; the main window keeps acquiring.</p></item> 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> </list>
</section> </section>
+53 -3
View File
@@ -448,7 +448,8 @@ bool load_layout(const std::string& path, LayoutState& out) {
std::string line; std::string line;
// What the last `[[...]]` header opened, so keys land in the right record. // 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; int plot_count = 0;
bool plot_section_seen = false; bool plot_section_seen = false;
while (std::getline(in, line)) { while (std::getline(in, line)) {
@@ -459,6 +460,14 @@ bool load_layout(const std::string& path, LayoutState& out) {
section = Section::Plot; section = Section::Plot;
plot_section_seen = true; plot_section_seen = true;
st.rows.emplace_back(); 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]]") { } else if (t == "[[calibration]]") {
section = Section::Calibration; section = Section::Calibration;
st.calibration.emplace_back(); st.calibration.emplace_back();
@@ -608,6 +617,35 @@ bool load_layout(const std::string& path, LayoutState& out) {
} catch (...) { } 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()) { } else if (section == Section::Plot && !st.rows.empty()) {
if (key == "signals") { if (key == "signals") {
std::vector<std::string> names; 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"; if (state.buffer_samples > 0) o << "buffer_samples = " << state.buffer_samples << "\n";
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n"; o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\n"; if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\n";
for (const auto& row : state.rows) { const auto write_row = [&](const LayoutState::Row& row) {
o << "\n[[plot]]\n";
// A row of -1 is a state built without the split (nothing in the app does that now); leaving the // 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. // 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"; 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 << toml_escape(row.signals[i]);
} }
o << "]\n"; 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) { for (const auto& cal : state.calibration) {
o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain
+13
View File
@@ -36,6 +36,19 @@ struct LayoutState {
double window_s = 0.0; ///< 0 keeps the default window double window_s = 0.0; ///< 0 keeps the default window
std::vector<Row> rows; 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 { struct Calibration {
std::string name; std::string name;
double gain = 1.0; double gain = 1.0;
+23
View File
@@ -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) { void RowSet::set_cursor_mode(CursorMode mode) {
if (mode == cursor_mode_) return; if (mode == cursor_mode_) return;
cursor_mode_ = mode; 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}); 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() { void RowSet::fit_all() {
for (int i = 0; i < rows_; ++i) { for (int i = 0; i < rows_; ++i) {
Row& r = row(i); Row& r = row(i);
+21
View File
@@ -355,6 +355,16 @@ class RowSet {
void place_crossings(); void place_crossings();
CursorMode cursor_mode() const noexcept { return cursor_mode_; } 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 /// 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 /// 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 /// 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. /// its label stays empty.
struct MeasureMark { struct MeasureMark {
int row = 0; 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; bool level = false;
double value = 0.0; double value = 0.0;
plot::u32 color = 0; plot::u32 color = 0;
@@ -570,6 +584,13 @@ class RowSet {
/// 0, so nothing outside the pre+post window is visible. /// 0, so nothing outside the pre+post window is visible.
void pin_event_window(double pre_s, double post_s); 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 -- /// 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 /// `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. /// the time window**: the y axes are fitted to their data, x is merely un-zoomed.
+1
View File
@@ -50,6 +50,7 @@ void register_builtin_sources() {
if (done) return; if (done) return;
done = true; done = true;
register_kind(make_udps_kind()); register_kind(make_udps_kind());
register_kind(make_tcps_kind());
register_kind(make_e1608_kind()); register_kind(make_e1608_kind());
register_kind(make_flatudp_kind()); register_kind(make_flatudp_kind());
if (std::getenv("UDPSCOPE_PROBE_SOURCE_KINDS") != nullptr) register_kind(make_probe_kind()); if (std::getenv("UDPSCOPE_PROBE_SOURCE_KINDS") != nullptr) register_kind(make_probe_kind());
+2 -1
View File
@@ -21,8 +21,9 @@ const std::vector<SourceKind*>& kinds();
/// may drop. A later `dlopen` loader would only call `register_kind()`. /// may drop. A later `dlopen` loader would only call `register_kind()`.
void register_builtin_sources(); 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_udps_kind();
std::unique_ptr<SourceKind> make_tcps_kind();
std::unique_ptr<SourceKind> make_probe_kind(); std::unique_ptr<SourceKind> make_probe_kind();
std::unique_ptr<SourceKind> make_e1608_kind(); std::unique_ptr<SourceKind> make_e1608_kind();
std::unique_ptr<SourceKind> make_flatudp_kind(); std::unique_ptr<SourceKind> make_flatudp_kind();
+107
View File
@@ -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
+12 -5
View File
@@ -40,11 +40,14 @@ SignalInfo to_info(const udps_signal_t& s) {
} // namespace } // namespace
UdpsSource::UdpsSource(const SourceConfig& cfg) UdpsSource::UdpsSource(const SourceConfig& cfg, Transport transport)
: server_(cfg.get("server", "127.0.0.1")), : transport_(transport),
port_(static_cast<std::uint16_t>(cfg.get_int("port", 8000))), server_(cfg.get("server", "127.0.0.1")),
multicast_(cfg.get("multicast")), port_(static_cast<std::uint16_t>(
interface_(cfg.get("interface")), 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))) {} recv_buffer_(static_cast<int>(cfg.get_int("recv_buffer", 8 * 1024 * 1024))) {}
UdpsSource::~UdpsSource() { close(); } UdpsSource::~UdpsSource() { close(); }
@@ -62,6 +65,10 @@ void UdpsSource::open(SourceSink& sink) {
c.silence_timeout_s = 2.0; c.silence_timeout_s = 2.0;
c.reconnect_delay_s = 0.5; c.reconnect_delay_s = 0.5;
c.keepalive_interval_s = 1.0; 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); client_ = udps_client_create(&c);
if (client_ == nullptr) { if (client_ == nullptr) {
+6 -1
View File
@@ -18,7 +18,9 @@ namespace udpscope {
/// all protocol work stays in `deps/udpsclient`. /// all protocol work stays in `deps/udpsclient`.
class UdpsSource final : public DataSource { class UdpsSource final : public DataSource {
public: public:
explicit UdpsSource(const SourceConfig& cfg); enum class Transport { Udp, Tcp };
explicit UdpsSource(const SourceConfig& cfg, Transport transport = Transport::Udp);
~UdpsSource() override; ~UdpsSource() override;
void open(SourceSink& sink) override; void open(SourceSink& sink) override;
@@ -27,6 +29,7 @@ class UdpsSource final : public DataSource {
SourceStats stats() const override; SourceStats stats() const override;
bool connected() const override; bool connected() const override;
void set_datagram_tap(DatagramTap tap) override { tap_ = std::move(tap); } void set_datagram_tap(DatagramTap tap) override { tap_ = std::move(tap); }
Transport transport() const { return transport_; }
private: private:
static void config_cb(const udps_signal_t* signals, std::uint32_t count, 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, static void datagram_cb(const std::uint8_t* bytes, std::size_t len, double recv_time,
void* user); void* user);
Transport transport_ = Transport::Udp;
std::string server_; std::string server_;
std::uint16_t port_ = 8000; std::uint16_t port_ = 8000;
std::string multicast_; std::string multicast_;
std::string interface_; std::string interface_;
std::string stream_;
int recv_buffer_ = 8 * 1024 * 1024; int recv_buffer_ = 8 * 1024 * 1024;
udps_client_t* client_ = nullptr; udps_client_t* client_ = nullptr;
+907 -15
View File
File diff suppressed because it is too large. Load diff
+102 -1
View File
@@ -215,6 +215,40 @@ struct App {
GtkWindow* window = nullptr; GtkWindow* window = nullptr;
plot::gtk4::PlotArea* area = nullptr; plot::gtk4::PlotArea* area = nullptr;
GtkWidget* widget = 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 /// 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 /// outlives the widget, and sharing the main window's placeholder would hand two areas
/// one plot the moment the figure mode is ever left. /// one plot the moment the figure mode is ever left.
@@ -222,6 +256,14 @@ struct App {
std::unique_ptr<RowSet> rows; std::unique_ptr<RowSet> rows;
}; };
std::vector<std::unique_ptr<ScopeWindow>> scopes; 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 /// 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. /// 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). /// count changes (`maximize_buttons_rows` is the count they were built for).
std::vector<GtkWidget*> maximize_buttons; std::vector<GtkWidget*> maximize_buttons;
int maximize_buttons_rows = -1; 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 /// 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. /// began, so the beginning has to be kept -- reading the weights while dragging would compound.
int layout_drag_row = -1; int layout_drag_row = -1;
@@ -870,6 +916,7 @@ struct App {
int dbg_maximize_step = 0; int dbg_maximize_step = 0;
std::size_t dbg_maximize_single = 0; std::size_t dbg_maximize_single = 0;
int dbg_maximize_hidden = -1; 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`). /// Which step of the scope-window probe runs next (`UDPSCOPE_PROBE_SCOPE_MS`).
int dbg_scope_step = 0; int dbg_scope_step = 0;
int cursors_pane_mode = -1; int cursors_pane_mode = -1;
@@ -1627,6 +1674,9 @@ struct ExportContext {
}; };
struct FixedRangeContext { struct FixedRangeContext {
App* app; 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; int row;
GtkWidget* lo; GtkWidget* lo;
GtkWidget* hi; 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); 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); 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); 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 /// 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. /// one control in two places, and `panel_switch_sync` keeps the write from looking like a click.
void sync_panel_switches(App& app); 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 /// 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 /// 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. /// 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_probe(gpointer user);
gboolean measurepin_held_wait(gpointer user); gboolean measurepin_held_wait(gpointer user);
gboolean measurepin_pause_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_report(gpointer user);
gboolean flatudp_probe_gap(gpointer user); gboolean flatudp_probe_gap(gpointer user);
gboolean flatudp_probe_resumed(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 pan_probe(gpointer user);
gboolean arm_probe(gpointer user); gboolean arm_probe(gpointer user);
gboolean trigrenum_probe_step3(gpointer user); gboolean trigrenum_probe_step3(gpointer user);
+50
View File
@@ -14,6 +14,46 @@ namespace ui_impl {
/// Reads the session layout back out of the model and rows: the display window, /// 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. /// 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 collect_layout(App& app) {
LayoutState st; LayoutState st;
st.window_s = app.rows->time_window(); st.window_s = app.rows->time_window();
@@ -64,6 +104,12 @@ LayoutState collect_layout(App& app) {
if (model.signals()[si].math || !on[si]) continue; if (model.signals()[si].math || !on[si]) continue;
st.enabled.push_back(model.signals()[si].name); 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 // 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. // writing those out would freeze a colour that is meant to follow the signal list.
for (const auto& [name, rgb] : app.rows->signal_colors()) { for (const auto& [name, rgb] : app.rows->signal_colors()) {
@@ -437,6 +483,10 @@ void apply_layout(App& app, const LayoutState& st) {
break; 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.measures = st.measures;
app.measure_stats_on = st.measure_stats; app.measure_stats_on = st.measure_stats;
app.updating_panels = true; app.updating_panels = true;
+2
View File
@@ -296,6 +296,7 @@ void place_measure_marks(App& app) {
} }
} }
app.rows->set_measure_marks(app.measure_marks_display); 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 /// 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). // `MeasureMarkSpec` -- the badge text is built where the display conversion happens).
RowSet::MeasureMark mark; RowSet::MeasureMark mark;
mark.row = row; mark.row = row;
mark.signal = m.signal;
mark.level = spec.level; mark.level = spec.level;
mark.value = spec.level ? spec.value : spec.value + s0; mark.value = spec.level ? spec.value : spec.value + s0;
mark.color = color; mark.color = color;
+3
View File
@@ -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 // 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. // plots, and a change is the next frame's drawing.
app.rows->set_crosshair_axis_labels(s.crosshair_axis_labels); 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 // 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. // 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); if (!written) set_notice(app, "could not write the preferences file", 4);
+404 -19
View File
@@ -11,6 +11,7 @@
#include "source/flatudp/flatudp_signals.hpp" #include "source/flatudp/flatudp_signals.hpp"
#include "source/flatudp/flatudp_source.hpp" #include "source/flatudp/flatudp_source.hpp"
#include "source/registry.hpp" #include "source/registry.hpp"
#include "source/udps_source.hpp"
#include "ui_app.hpp" #include "ui_app.hpp"
#include <sys/stat.h> #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 // 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. // *to*); the button appears together with the second figure row, below.
a->dbg_maximize_single = a->maximize_buttons.size(); 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->set_cells_per_row({1, 1});
a->rows->assign(0, 0, sine, sine, sine_color); a->rows->assign(0, 0, sine, sine, sine_color);
a->rows->assign(1, 0, square, square, square_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 std::string row1_back = a->rows->row(1).signal_name[0];
const bool flag_back = a->rows->figures_maximized(); const bool flag_back = a->rows->figures_maximized();
std::fprintf(stderr, std::fprintf(stderr,
"[maximize-probe] single_buttons=%zu before: rows=%d frows=%d row0=%s row1=%s " "[maximize-probe] single_buttons=%zu detach_single=%zu before: rows=%d frows=%d "
"buttons=%zu; max: rows=%d frows=%d flag=%d row0=%s; " "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", "back: rows=%d row0=%s row1=%s flag=%d\n",
a->dbg_maximize_single, rows_before, frows_before, row0_before.c_str(), a->dbg_maximize_single, a->dbg_detach_single, rows_before, frows_before,
row1_before.c_str(), buttons, rows_max, frows_max, flag_max ? 1 : 0, 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(), row0_max.c_str(), rows_back, row0_back.c_str(), row1_back.c_str(),
flag_back ? 1 : 0); flag_back ? 1 : 0);
std::fflush(stderr); std::fflush(stderr);
@@ -1703,25 +1707,37 @@ gboolean maximize_probe_step(gpointer user) {
case 2: { case 2: {
a->dbg_maximize_hidden = a->dbg_maximize_hidden =
a->maximize_buttons.empty() || !gtk_widget_get_visible(a->maximize_buttons[0]) ? 1 : 0; a->maximize_buttons.empty() || !gtk_widget_get_visible(a->maximize_buttons[0]) ? 1 : 0;
a->area_pointer_x = static_cast<double>(gtk_widget_get_width(a->area_widget)) - 8.0; // On the **detach** button's own centre -- its zone is the strictest of the corner pair, so a
a->area_pointer_y = 8.0; // 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->area_pointer_valid = true;
a->dbg_maximize_step = 3; a->dbg_maximize_step = 3;
g_timeout_add(250, maximize_probe_step, a); g_timeout_add(250, maximize_probe_step, a);
break; break;
} }
case 3: { case 3: {
const int shown = !a->maximize_buttons.empty() && const auto visible = [](const std::vector<GtkWidget*>& buttons, std::size_t i) {
gtk_widget_get_visible(a->maximize_buttons[0]) return i < buttons.size() && gtk_widget_get_visible(buttons[i]) ? 1 : 0;
? 1 };
: 0; const int shown = visible(a->maximize_buttons, 0);
const int other = a->maximize_buttons.size() > 1 && const int other = visible(a->maximize_buttons, 1);
gtk_widget_get_visible(a->maximize_buttons[1]) const int dshown = visible(a->detach_buttons, 0);
? 1 const int dother = visible(a->detach_buttons, 1);
: 0;
std::fprintf(stderr, "[maximize-probe] corner: hidden_when_off=%d shown_when_near=%d " std::fprintf(stderr, "[maximize-probe] corner: hidden_when_off=%d shown_when_near=%d "
"other_row=%d\n", "other_row=%d detach_near=%d detach_other=%d\n",
a->dbg_maximize_hidden, shown, other); a->dbg_maximize_hidden, shown, other, dshown, dother);
std::fflush(stderr); std::fflush(stderr);
return G_SOURCE_REMOVE; 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 /// 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 /// creation itself (a real `GtkWindow`, an own `PlotArea`, its own `RowSet`) is exercised by
/// the same call the two menu entries make. /// 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) { gboolean scope_probe_step(gpointer user) {
App* a = static_cast<App*>(user); App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE; if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_scope_step) { switch (a->dbg_scope_step) {
case 0: { 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(); const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) { for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == "sine") a->model->set_signal_enabled(i, true); 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& copy = *a->scopes[0];
const App::ScopeWindow& empty = *a->scopes[1]; const App::ScopeWindow& empty = *a->scopes[1];
const std::string copied = copy.rows->row(0).signal_name[0]; // The first non-empty slot of the copied row: the copy is slot-for-slot faithful, and slot 0
const bool copied_bound = copy.rows->row(0).channel[0] >= 0; // is empty whenever an earlier probe (the enable-all one disables a signal at its end) left
const bool empty_ok = empty.rows->row(0).signal_name[0].empty(); // 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 && const bool windows = copy.window != nullptr && empty.window != nullptr &&
gtk_widget_get_visible(GTK_WIDGET(copy.window)) && gtk_widget_get_visible(GTK_WIDGET(copy.window)) &&
gtk_widget_get_visible(GTK_WIDGET(empty.window)); gtk_widget_get_visible(GTK_WIDGET(empty.window));
std::fprintf(stderr, std::fprintf(stderr,
"[scope-probe] count=2 copied='%s' bound=%d empty_ok=%d windows=%d\n", "[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); 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); std::fflush(stderr);
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
} }
@@ -2409,6 +2712,10 @@ gboolean source_kinds_probe(gpointer user) {
c.set("rate", "2500"); c.set("rate", "2500");
c.set("endian", "big"); c.set("endian", "big");
c.set("signals", "Trigger:uint8,Ia:float32,Wave:int16[4]"); 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; return c;
}; };
@@ -3394,6 +3701,84 @@ gboolean flatudp_probe(gpointer user) {
return G_SOURCE_REMOVE; 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) { gboolean pan_probe(gpointer user) {
App* a = static_cast<App*>(user); App* a = static_cast<App*>(user);
if (a->window == nullptr || a->rows->row_count() <= 0) return G_SOURCE_REMOVE; if (a->window == nullptr || a->rows->row_count() <= 0) return G_SOURCE_REMOVE;
+42
View File
@@ -288,6 +288,48 @@ void fill(App& app, const std::string& name, GtkWidget* page) {
&app); &app);
} }
gtk_box_append(GTK_BOX(page), rows_box); 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); GtkWidget* off = text_button("Remove from plots", page);
gtk_widget_set_halign(off, GTK_ALIGN_START); gtk_widget_set_halign(off, GTK_ALIGN_START);
gtk_widget_set_sensitive(off, plotted); gtk_widget_set_sensitive(off, plotted);
+86 -17
View File
@@ -505,24 +505,26 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
app.updating_panels = false; app.updating_panels = false;
} }
bool assign_signal_to_row(App& app, std::size_t signal, int row) { /// The drop's one-line report, wherever the drop landed: the notice bar and the log, because a
if (signal >= app.model->signals().size()) return false; /// refusal a reader cannot act on is only half an answer.
void drop_notice(App& app, const std::string& what) {
const auto notice = [&](const std::string& what) { set_notice(app, what, 4);
set_notice(app, what, 4); std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str());
std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str()); std::fflush(stderr);
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 // 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. // first, or the first element if the signal is a vector.
const std::vector<ChannelInfo>& channels = app.model->channels(); const std::vector<ChannelInfo>& channels = app.model->channels();
int channel = -1; int channel = -1;
std::string name;
for (std::size_t i = 0; i < channels.size(); ++i) { for (std::size_t i = 0; i < channels.size(); ++i) {
if (channels[i].signal == signal) { if (channels[i].signal == signal) {
channel = static_cast<int>(i); channel = static_cast<int>(i);
name = channels[i].name; *name = channels[i].name;
break; break;
} }
} }
@@ -533,14 +535,22 @@ bool assign_signal_to_row(App& app, std::size_t signal, int row) {
for (std::size_t i = 0; i < again.size(); ++i) { for (std::size_t i = 0; i < again.size(); ++i) {
if (again[i].signal == signal) { if (again[i].signal == signal) {
channel = static_cast<int>(i); channel = static_cast<int>(i);
name = again[i].name; *name = again[i].name;
break; 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) { 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)"); "' (multi-element signals are not acquired)");
return false; return false;
} }
@@ -554,9 +564,9 @@ bool assign_signal_to_row(App& app, std::size_t signal, int row) {
if (slot < 0) { if (slot < 0) {
// Say what to do about it. A refusal a reader cannot act on is only half an answer: the two ways // 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. // out are another plot or another row.
notice("plot " + app.rows->plot_label(row) + 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 " " holds four signals already: drop it on another plot, or add a row with "
"editor (L)"); "the layout editor (L)");
return false; return false;
} }
// The slot remembers the *signal* (and element), never the channel index: the // 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); schedule_layout_save(app);
app.relayout_pending = true; app.relayout_pending = true;
rebuild_signal_panels(app); // the row's colour chip now has a trace to name 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; 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, gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y,
gpointer user) { gpointer user) {
App* app = static_cast<App*>(user); App* app = static_cast<App*>(user);
+2 -2
View File
@@ -443,8 +443,8 @@ constexpr TourStep kTourSteps[] = {
{"plot", nullptr, "The plot", {"plot", nullptr, "The plot",
"Every signal you have switched on is drawn here, all plots sharing one time axis -- and the " "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 " "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 " "a plot opens its own menu: fit, full screen and detach (the corner buttons come out as the "
"as the pointer nears it), a second window showing that figure, and the layout editor, which " "pointer nears them), a second window showing that figure, and the layout editor, which "
"splits a row into up to four plots.", "splits a row into up to four plots.",
&App::area_overlay}, &App::area_overlay},
{"badges", nullptr, "The badges", {"badges", nullptr, "The badges",
+119
View File
@@ -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()